AutoCore User Manual
Welcome to AutoCore
What Is AutoCore?
AutoCore is an industrial automation platform that runs on standard PC hardware. If you have used TwinCAT, Codesys, or acontis, you can think of AutoCore as a modern alternative that replaces proprietary IDEs and runtimes with open tools and standard programming languages.
With AutoCore you can:
- Write control programs in Rust that execute in a deterministic, real-time loop (1kHz - 4kHz or faster).
- Connect to field devices via EtherCAT and Modbus TCP, with the same kind of cyclic I/O exchange you are familiar with from TwinCAT.
- Build web-based HMIs using React and TypeScript instead of proprietary visualization tools.
- Deploy and monitor using a simple command-line tool (
acctl) that works over the network.
AutoCore runs on Linux. Your development machine can be either an Ubuntu desktop or a Windows 11 Pro machine using WSL2 (Windows Subsystem for Linux).
How AutoCore Compares to Traditional PLCs
If you are coming from a TwinCAT or Codesys background, this table will help you map familiar concepts:
| TwinCAT / Codesys | AutoCore | Notes |
|---|---|---|
| TwinCAT XAE (Visual Studio) | VS Code + Rust + acctl | You write code in any editor; acctl handles build and deploy |
| PLC Runtime | autocore-server | The server process that manages the control loop, I/O, and communication |
| PLC Program (ST/FBD/LD) | control/src/program.rs | Your control logic, written in Rust |
| Global Variable List (GVL) | project.json variables + gm.rs | Variables are declared in JSON; a Rust struct is auto-generated |
| I/O Configuration (XAE) | project.json modules section | EtherCAT slaves, Modbus devices, etc. are configured in JSON |
| EtherCAT Master | autocore-ethercat module | Runs as a separate process; maps I/O into shared memory |
| Modbus TCP Client | autocore-modbus module | Same pattern — separate process, shared memory I/O |
| TwinCAT HMI / Visualization | www/ directory (React app) | Web-based HMI accessible from any browser |
| ADS Protocol | WebSocket JSON API | All monitoring and HMI communication uses WebSockets |
| TcSysManager | acctl CLI | Project creation, deployment, status, log streaming |
| Scan cycle / task cycle | Tick signal | Server-generated timing signal, configurable in microseconds |
Key Concepts
Before you begin, here are the terms you will encounter throughout this manual:
- autocore-server: The main process that manages everything — shared memory, the tick signal, communication, modules, and the web interface.
- Control program: Your Rust application that runs the real-time control logic. It is a separate process from the server, synchronized via shared memory.
- acctl: The command-line tool you use to create projects, deploy code, monitor logs, and manage the server.
- project.json: The single configuration file that defines your entire automation project — variables, hardware modules, cycle time, and more.
- Global Memory (GM): A shared memory region that all processes (control program, EtherCAT driver, Modbus driver, etc.) can read from and write to with zero-copy performance.
- Tick: A periodic timing signal generated by the server. Your control program executes one cycle per tick.
- Module: An external process (EtherCAT master, Modbus client, camera driver, etc.) that connects to the server and exchanges data through shared memory and IPC.
- Variable: A named piece of data in global memory. Variables have a type (e.g.,
u16,bool,f32) and optionally a link to hardware I/O. - FQDN (Fully Qualified Domain Name): The address of any resource in the system, using dot-separated segments. For example,
gm.motor_speedaddresses themotor_speedvariable, andethercat.drive_0.rxpdo_1.controlwordaddresses a specific EtherCAT PDO entry.
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:
- Create a new
.mdfile indoc/book/src/ - Add a
- [Title](filename.md)entry toSUMMARY.md - Run
mdbook serveto 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.tomlfiles - Error Lens: Shows errors inline in the editor
Installing the Custom WSL2 Kernel
AutoCore provides a pre-built custom WSL2 kernel that enables loadable kernel modules (LKM). The stock WSL2 kernel has restricted module support, which prevents the EtherCAT master and other kernel drivers from loading. Even if you do not plan to use EtherCAT from WSL2, the custom kernel is recommended for full compatibility with AutoCore.
Option 1: Use the Pre-Built Kernel (Recommended)
If you received the pre-built kernel file (bzImage) from your AutoCore distribution:
- Copy the kernel file to your Windows user folder:
# In PowerShell
copy <path-to-bzImage> $HOME\wsl_kernels\bzImage
- Skip to Configure WSL2 to Use the Custom Kernel below.
Option 2: Build the Kernel from Source
If you need to build the kernel yourself (e.g., for a specific kernel version):
- Clone the WSL2 kernel source. Check your current version with
uname -r, then clone the matching branch:
git clone --depth 1 -b linux-msft-wsl-6.6.y https://github.com/microsoft/WSL2-Linux-Kernel.git
cd WSL2-Linux-Kernel
- Configure for module support:
cp Microsoft/config-wsl .config
./scripts/config --enable CONFIG_MODULES
./scripts/config --enable CONFIG_MODULE_UNLOAD
./scripts/config --enable CONFIG_MODVERSIONS
./scripts/config --set-str CONFIG_LOCALVERSION "-autocore"
- Build the kernel and modules:
make -j$(nproc)
sudo make modules_install
sudo make install
This takes 10-30 minutes depending on your machine.
- Copy the kernel image to Windows:
cp arch/x86/boot/bzImage /mnt/c/Users/<Windows_User>/wsl_kernels/bzImage
Replace <Windows_User> with your actual Windows username (check with ls /mnt/c/Users/).
Configure WSL2 to Use the Custom Kernel
On Windows, create or edit the file C:\Users\<Windows_User>\.wslconfig:
[wsl2]
kernel=C:\\Users\\<Windows_User>\\wsl_kernels\\bzImage
networkingMode=mirrored
The networkingMode=mirrored setting is important — it makes your physical Windows Ethernet adapters visible inside WSL2 with their real MAC addresses. This is required for EtherCAT development and also simplifies accessing the AutoCore web console.
Now restart WSL entirely from PowerShell:
wsl --shutdown
Then re-launch your instance:
wsl -d AutoCore-Dev
Verify the custom kernel is running:
uname -a
You should see output like:
Linux YOURPC 6.6.114.1-autocore+ #2 SMP PREEMPT_DYNAMIC ... x86_64 GNU/Linux
The -autocore (or -ethercat-local) suffix confirms you are running the custom kernel.
Configure Networking (WSL2)
With networkingMode=mirrored in your .wslconfig, your WSL2 instance shares the host’s network interfaces. This means:
- You can access the AutoCore web console at
http://localhost:8080directly from Windows. - The
acctltool can reach remote AutoCore servers on your network without any port forwarding. - Physical Ethernet adapters are visible for EtherCAT (see below).
If you are not using mirrored mode, WSL2 has its own IP address. Find it with:
hostname -I
Then access the web console at http://<WSL2_IP>:8080 from Windows.
Setting Up EtherCAT in WSL2 (Optional)
If you plan to connect to physical EtherCAT hardware from your Windows development machine (for testing and commissioning), follow these steps. If you are only writing and compiling control programs and will deploy to a separate target machine, you can skip this section.
Reminder: EtherCAT from WSL2 is for development and testing only. Production systems should run on a dedicated Linux PC with a real-time kernel.
Step 1: Build and Install the EtherLab EtherCAT Master
With the custom kernel running, compile the EtherCAT master against the kernel source:
# Clone the EtherLab repository
git clone https://gitlab.com/etherlab.org/ethercat.git
cd ethercat
./bootstrap
# Configure — WSL2 requires the generic driver (no direct PCI access)
./configure --prefix=/opt/etherlab \
--sysconfdir=/etc \
--disable-8139too \
--enable-generic \
--with-linux-dir=$HOME/WSL2-Linux-Kernel
# Build and install
make -j$(nproc)
make modules
sudo make install
sudo make modules_install
sudo depmod -a
Step 2: Configure the EtherCAT Master
Edit the configuration file:
sudo nano /etc/ethercat.conf
Set the following (you will update MASTER0_DEVICE later when connecting a USB Ethernet adapter):
MASTER0_DEVICE=""
DEVICE_MODULES="generic"
Step 3: Enable Non-Root Access
By default, only root can access the EtherCAT master device. Create a udev rule to allow your user:
echo 'KERNEL=="EtherCAT[0-9]*", MODE="0666"' | sudo tee /etc/udev/rules.d/99-ethercat.rules
Enable the EtherCAT service to start automatically:
sudo systemctl enable ethercat
Connecting USB Ethernet to WSL2 for EtherCAT
EtherCAT requires a dedicated Ethernet interface. In WSL2, the most reliable way to provide this is with a USB-to-Ethernet adapter passed through from Windows using usbipd-win.
First-Time Setup (Windows)
-
Install usbipd-win on Windows. Download the latest
.msifrom: https://github.com/dorssel/usbipd-win/releasesRun the installer and restart if prompted.
-
Build the usbip kernel modules inside WSL2 (needed for the custom kernel):
# Navigate to the USB tools in the kernel source
cd ~/WSL2-Linux-Kernel/tools/usb/usbip
# Build and install
./autogen.sh
./configure
make -j$(nproc)
sudo make install
sudo ldconfig
# Install USB utilities
sudo apt install -y usbutils
Connecting the Adapter
Each time you want to use EtherCAT, you need to attach the USB adapter to WSL2. This process has a Windows side and a Linux side.
On Windows (PowerShell as Administrator):
-
Plug in your USB-to-Ethernet adapter.
-
List USB devices to find the adapter’s bus ID:
usbipd list
Example output:
Connected:
BUSID VID:PID DEVICE STATE
2-4 0bda:8153 Realtek USB GbE Family Controller Not shared
6-7 06cb:00f9 Synaptics UWP WBDI Not shared
...
- Bind and attach the adapter (using the BUSID from above):
usbipd bind --busid 2-4
usbipd attach --wsl --busid 2-4
Note the IP address printed in the output — you may need it if the automatic attachment fails.
In WSL2:
- Load the USB host controller module (needed on first use after each WSL restart):
sudo modprobe vhci-hcd
If
modprobefails, you may need to manually attach. Use the IP address from the PowerShell output:sudo usbip attach -r <IP_FROM_POWERSHELL> -b 2-4
- Verify the adapter is visible:
ip link
You should see a new interface (e.g., enx6c6e0719971b or enpXs0):
1: lo: <LOOPBACK,UP,LOWER_UP> ...
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> ...
3: enx6c6e0719971b: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN ...
link/ether 6c:6e:07:19:97:1b brd ff:ff:ff:ff:ff:ff
- Bring the interface up:
sudo ip link set enx6c6e0719971b up
- Update the EtherCAT master configuration with the adapter name:
sudo nano /etc/ethercat.conf
Set the device to the adapter name from ip link:
MASTER0_DEVICE="enx6c6e0719971b"
DEVICE_MODULES="generic"
- Start (or restart) the EtherCAT service:
sudo systemctl restart ethercat
- Verify it is working:
ethercat master
ethercat slaves
You should see your EtherCAT master status and any connected slaves:
Master0
Phase: Idle
Active: no
Slaves: 1
Ethernet devices:
Main: 6c:6e:07:19:97:1b (attached)
Link: UP
...
Tip: The USB adapter attachment does not persist across WSL restarts. After a
wsl --shutdownor system reboot, you will need to re-run theusbipd attachcommand from PowerShell and themodprobe/ip link set upcommands from WSL2.
Now continue to Installing the Rust Toolchain.
Option B: Ubuntu Desktop
If you are using a native Ubuntu 22.04 or 24.04 installation, the setup is straightforward.
Step 1: Update Your System
sudo apt update && sudo apt upgrade -y
Step 2: Install Build Dependencies
sudo apt install -y build-essential pkg-config libssl-dev git curl
Step 3: Set Up Your Code Editor
Install Visual Studio Code:
sudo snap install code --classic
Or download it from the VS Code website and install with:
sudo dpkg -i code_*.deb
sudo apt install -f
Recommended VS Code extensions:
- rust-analyzer: Rust language support
- Even Better TOML: Syntax highlighting for
Cargo.tomlfiles - Error Lens: Shows errors inline in the editor
Now continue to Installing the Rust Toolchain.
Installing the Rust Toolchain
AutoCore control programs are written in Rust. Install the Rust toolchain using rustup, the official installer:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
When prompted, select the default installation (option 1). After installation completes, load the new environment:
source "$HOME/.cargo/env"
Verify the installation:
rustc --version
cargo --version
You should see version numbers for both. The minimum supported Rust version for AutoCore is 1.85.0 (Rust 2024 edition).
What is Cargo? Cargo is Rust’s build tool and package manager — similar to
npmfor JavaScript orpipfor Python. You will usecargo buildto compile control programs andcargo installto install tools likeacctl.
Installing Node.js (for Web HMI Development)
If you plan to build a web-based HMI for your machine, you will need Node.js. If you only need to write control programs, you can skip this step.
Install Node.js using the NodeSource repository:
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
Verify:
node --version
npm --version
Installing AutoCore
AutoCore consists of two components you install on your development machine:
- autocore-server — the runtime engine
- acctl — the command-line project management tool
Installing from a Debian Package (Recommended)
If you received a .deb package file:
sudo dpkg -i autocore_server_*.deb
sudo apt install -f # Install any missing dependencies
This installs:
- The server binary to
/opt/autocore/bin/autocore_server - Module binaries (EtherCAT, Modbus) to
/opt/autocore/bin/modules/ - The standard library to
/srv/autocore/lib/autocore-std/ - The web console to
/srv/autocore/console/ - A systemd service for automatic startup
- Default configuration to
/opt/autocore/config/config.ini
Enable and start the server:
sudo systemctl enable autocore_server
sudo systemctl start autocore_server
Installing acctl
The acctl CLI tool is installed separately using Cargo:
cargo install --path /path/to/autocore-server/acctl
Or, if you received acctl as a standalone package:
cargo install acctl
Manual Configuration (Development Setup)
If you are building from source or using a development setup, you need a config.ini file. Create one at /opt/autocore/config/config.ini (or run the server with --config /path/to/config.ini):
[console]
port = 11969
www_root = /srv/autocore/console/dist
[general]
projects_directory = /srv/autocore/projects
module_base_directory = /opt/autocore/bin/modules
port = 8080
autocore_std_directory = /srv/autocore/lib/autocore-std
disable_ads = 1
ipc_port = 9100
project_name = default
[modules]
modbus = ${general.module_base_directory}/autocore-modbus
ethercat = ${general.module_base_directory}/autocore-ethercat
| Setting | Description |
|---|---|
console.port | WebSocket port for CLI and web clients |
console.www_root | Path to the web console static files |
general.projects_directory | Root directory where all projects are stored |
general.port | HTTP port for the web server |
general.autocore_std_directory | Path to the autocore-std library (used for building control programs on the server) |
general.ipc_port | TCP port for module IPC communication |
general.project_name | The project to load on startup |
modules.* | Paths to module executables |
Verifying Your Installation
Run these commands to confirm everything is working:
# Check the Rust toolchain
rustc --version
cargo --version
# Check acctl
acctl --help
# Check if the server is running
sudo systemctl status autocore_server
# Check server status via acctl (if server is running locally)
acctl status
If acctl status shows the server version and a list of projects, your installation is complete.
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 / File | Purpose | When You Edit It |
|---|---|---|
project.json | Defines variables, hardware modules, cycle time | When adding variables, changing cycle time, or configuring hardware |
control/src/program.rs | Your control logic | This is where you spend most of your time |
control/src/main.rs | Entry point — connects to the server | Never (auto-generated) |
control/src/gm.rs | Rust struct mapping your variables | Never (auto-generated by acctl codegen) |
control/Cargo.toml | Rust dependencies | When adding external Rust libraries |
www/ | Web-based HMI | When building operator screens |
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:
| Field | Description | Example |
|---|---|---|
enable | Whether the control program should run | true |
source_directory | Path to the Rust source code | "./control" |
entry_point | The main Rust file | "main.rs" |
signals.tick.scan_rate_us | Cycle time in microseconds | 10000 (= 10 ms = 100 Hz) |
signals.tick.source | Where the tick comes from | "internal" (server-generated) |
Common cycle times:
| scan_rate_us | Cycle Time | Frequency | Typical Use |
|---|---|---|---|
1000 | 1 ms | 1 kHz | High-speed motion control |
2000 | 2 ms | 500 Hz | Servo drives |
5000 | 5 ms | 200 Hz | General motion |
10000 | 10 ms | 100 Hz | Process control, I/O |
50000 | 50 ms | 20 Hz | Slow processes, monitoring |
variables — Defines all the data points in your system. We will cover this in detail in Working with Variables.
modules — Configures hardware interface modules (EtherCAT, Modbus, etc.). We will cover this in the hardware integration chapters.
Building and Running Locally
If you are running the AutoCore server on your development machine (which is the typical development workflow):
# Step 1: Push the project configuration to the server
acctl push project
# Step 2: Build and deploy the control program, then start it
acctl push control --start
The push control command:
- Compiles your Rust control program
- Uploads the binary to the server
- With
--start, starts the control program immediately
If you only want to build without starting:
acctl push control
Then start it separately:
acctl control start
Viewing Logs
Your control program’s log output is captured by the server and can be viewed with:
# Show recent logs
acctl logs
# Stream logs in real time (like tail -f)
acctl logs --follow
Press Ctrl+C to stop streaming.
You can also check the control program’s status:
acctl control status
This shows whether the program is running, stopped, or has encountered an error.
Writing Control Programs
The Control Loop
AutoCore’s control program follows a familiar pattern if you have worked with PLC programs:
- The server generates a tick at a fixed interval (e.g., every 10 ms).
- On each tick, the control program:
- Reads all inputs from shared memory
- Executes your control logic (
process_tick) - Writes all outputs back to shared memory
- External modules (EtherCAT, Modbus) synchronize their I/O data with the same shared memory.
This is equivalent to a cyclic task in TwinCAT or a POU assigned to a periodic task in Codesys.
┌─────────────────────────────────────────┐
│ autocore-server │
│ │
Tick │ Shared Memory (autocore_cyclic) │
──────►│ ┌─────────┐ ┌──────────┐ ┌───────────┐ │
10ms │ │ Inputs │ │ Outputs │ │ Internal │ │
│ └────▲────┘ └────┬─────┘ └───────────┘ │
│ │ │ │
└───────┼───────────┼───────────────────────┘
│ │
┌──────┴───────────┴──────┐
│ Control Program │
│ (your program.rs) │
│ │
│ 1. Read inputs │
│ 2. Execute logic │
│ 3. Write outputs │
└──────────────────────────┘
Your First Control Program: A Counter
Let’s start with the simplest possible control program — a counter that increments every cycle. Open control/src/program.rs:
#![allow(unused)]
fn main() {
use autocore_std::{ControlProgram, TickContext};
use crate::gm::GlobalMemory;
pub struct MyControlProgram {
counter: u64,
}
impl MyControlProgram {
pub fn new() -> Self {
Self { counter: 0 }
}
}
impl ControlProgram for MyControlProgram {
type Memory = GlobalMemory;
fn initialize(&mut self, _mem: &mut Self::Memory) {
log::info!("Control program started!");
}
fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
self.counter += 1;
// Log every 1000 cycles (= every 10 seconds at 10ms cycle time)
if self.counter % 1000 == 0 {
log::info!("Cycle count: {}", self.counter);
}
}
}
}
What is happening here:
MyControlProgramis a struct that holds your program’s state. Thecounterfield is internal to the control program — it is not shared with other processes.new()is called once at startup to create the program instance.initialize()is called once after the program connects to shared memory. Use it for one-time setup.process_tick()is called every cycle (every 10 ms by default). This is where all your control logic goes.ctx.gmgives you access to the global memory (shared variables).ctx.clientgives you access to the command client for sending messages to modules.ctx.cycleis the current cycle number (starts at 1).
Build and deploy:
acctl push control --start
acctl logs --follow
You should see "Control program started!" followed by "Cycle count: 1000" every 10 seconds.
Working with Variables
Variables are the bridge between your control program and the outside world. They are declared in project.json and automatically become fields on the GlobalMemory struct in your Rust code.
Let’s add some variables. Edit project.json:
{
"name": "my_first_machine",
"version": "0.1.0",
"description": "AutoCore project: my_first_machine",
"modules": {},
"control": {
"enable": true,
"source_directory": "./control",
"entry_point": "main.rs",
"signals": {
"tick": {
"description": "System Tick (10ms)",
"source": "internal",
"scan_rate_us": 10000
}
}
},
"variables": {
"cycle_counter": {
"type": "u32",
"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:
| Type | Size | Range | Equivalent in IEC 61131-3 |
|---|---|---|---|
bool | 1 byte | true / false | BOOL |
u8 | 1 byte | 0 to 255 | USINT / BYTE |
i8 | 1 byte | -128 to 127 | SINT |
u16 | 2 bytes | 0 to 65,535 | UINT / WORD |
i16 | 2 bytes | -32,768 to 32,767 | INT |
u32 | 4 bytes | 0 to 4,294,967,295 | UDINT / DWORD |
i32 | 4 bytes | -2,147,483,648 to 2,147,483,647 | DINT |
u64 | 8 bytes | 0 to 18,446,744,073,709,551,615 | ULINT / LWORD |
i64 | 8 bytes | -(2^63) to (2^63 - 1) | LINT |
f32 | 4 bytes | IEEE 754 single-precision float | REAL |
f64 | 8 bytes | IEEE 754 double-precision float | LREAL |
Tip: Use
u16ori16for Modbus registers (which are 16-bit). Useboolfor digital I/O. Usef32for analog values and setpoints. Useu32/i32for EtherCAT encoder positions and counters.
Links
A variable can be linked to a hardware I/O point. When a variable has a "link" field, the system automatically synchronizes it with the corresponding hardware register:
"motor_speed": {
"type": "u16",
"link": "modbus.vfd_01.holding_0",
"description": "Speed command to VFD"
}
The link format is: module_name.device_name.register_name.
Variables without a "link" are purely software variables — they exist in shared memory and can be read/written by the control program, the web console, or other processes, but they are not connected to any hardware.
Reading Inputs and Writing Outputs
Inside process_tick, you access variables directly as struct fields on ctx.gm:
#![allow(unused)]
fn main() {
fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
// Reading an input (e.g., a sensor value)
let temperature = ctx.gm.temperature_sensor;
// Reading a command (e.g., a setpoint from the HMI)
let target_temp = ctx.gm.temperature_setpoint;
// Writing a status (e.g., for the HMI to display)
ctx.gm.temperature_error = target_temp - temperature;
// Writing an output (e.g., to a heater)
ctx.gm.heater_power = if temperature < target_temp { 100 } else { 0 };
}
}
There is no special API for reading or writing — variables are plain Rust fields. The ControlRunner handles all the shared memory synchronization before and after your process_tick call.
Using Logging
AutoCore provides a logging system that works inside the real-time control loop. Log messages are sent to the server and can be viewed with acctl logs.
#![allow(unused)]
fn main() {
fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
// Available log levels (from least to most severe):
log::trace!("Very detailed debug info");
log::debug!("Debug information");
log::info!("Normal operational messages");
log::warn!("Warning: something unexpected");
log::error!("Error: something went wrong");
// Use format strings just like println!
log::info!("Temperature: {:.1}°C, Setpoint: {:.1}°C",
ctx.gm.temperature_sensor,
ctx.gm.temperature_setpoint
);
}
}
Warning: Logging inside
process_tickhappens on every cycle. At 100 Hz, logging every cycle would produce 100 messages per second. Use a counter or a condition to limit logging:#![allow(unused)] fn main() { // Log only every 5 seconds (500 cycles at 10ms) if ctx.cycle % 500 == 0 { log::info!("Status: speed={:.0} RPM", ctx.gm.motor_speed_actual); } // Log only when a state changes if ctx.gm.machine_running && !self.was_running { log::info!("Machine started"); } self.was_running = ctx.gm.machine_running; }
Control Program Patterns and Examples
This chapter covers common patterns you will use in nearly every control program. If you have written PLC programs in Structured Text, you will recognize most of these.
State Machines
State machines are the most common pattern in machine control. In AutoCore, you define states as a Rust enum and transition between them in process_tick:
#![allow(unused)]
fn main() {
use autocore_std::{ControlProgram, TickContext};
use crate::gm::GlobalMemory;
#[derive(Debug, Clone, Copy, PartialEq)]
enum MachineState {
Idle,
Homing,
Ready,
Running,
Stopping,
Faulted,
}
pub struct MyControlProgram {
state: MachineState,
prev_state: MachineState,
}
impl MyControlProgram {
pub fn new() -> Self {
Self {
state: MachineState::Idle,
prev_state: MachineState::Idle,
}
}
}
impl ControlProgram for MyControlProgram {
type Memory = GlobalMemory;
fn initialize(&mut self, _mem: &mut Self::Memory) {
log::info!("Machine starting in Idle state");
}
fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
// Log state changes
if self.state != self.prev_state {
log::info!("State: {:?} -> {:?}", self.prev_state, self.state);
self.prev_state = self.state;
}
match self.state {
MachineState::Idle => {
ctx.gm.status_code = 0;
if ctx.gm.cmd_start {
self.state = MachineState::Homing;
}
}
MachineState::Homing => {
ctx.gm.status_code = 1;
// Perform homing sequence...
// When complete:
self.state = MachineState::Ready;
}
MachineState::Ready => {
ctx.gm.status_code = 2;
if ctx.gm.cmd_run {
self.state = MachineState::Running;
}
}
MachineState::Running => {
ctx.gm.status_code = 3;
// Main production logic here...
if ctx.gm.cmd_stop {
self.state = MachineState::Stopping;
}
if ctx.gm.emergency_stop {
self.state = MachineState::Faulted;
}
}
MachineState::Stopping => {
ctx.gm.status_code = 4;
// Decelerate, finish current operation...
// When stopped:
self.state = MachineState::Idle;
}
MachineState::Faulted => {
ctx.gm.status_code = 99;
ctx.gm.motor_enable = false;
if ctx.gm.cmd_reset {
self.state = MachineState::Idle;
}
}
}
}
}
}
Corresponding variables in project.json:
"variables": { "cmd_start": { "type": "bool", "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 Value | Current Value | Output |
|---|---|---|
false | false | false |
false | true | true (rising edge!) |
true | true | false |
true | false | false |
How FTrig works:
| Previous Value | Current Value | Output |
|---|---|---|
true | true | false |
true | false | true (falling edge!) |
false | false | false |
false | true | false |
Timers
The Ton (Timer On Delay) function block works like TON in IEC 61131-3. The output becomes true after the input has been true for a specified duration:
#![allow(unused)]
fn main() {
use autocore_std::fb::Ton;
use std::time::Duration;
pub struct MyControlProgram {
startup_delay: Ton,
fault_timer: Ton,
}
impl MyControlProgram {
pub fn new() -> Self {
Self {
startup_delay: Ton::new(),
fault_timer: Ton::new(),
}
}
}
impl ControlProgram for MyControlProgram {
type Memory = GlobalMemory;
fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
// Wait 3 seconds after machine_running becomes true
// before enabling the motor
let delay_done = self.startup_delay.call(
ctx.gm.machine_running,
Duration::from_secs(3),
);
ctx.gm.motor_enable = delay_done;
// If temperature is too high for more than 5 seconds, raise an alarm
let overtemp = ctx.gm.temperature > 80.0;
let alarm = self.fault_timer.call(overtemp, Duration::from_secs(5));
ctx.gm.temperature_alarm = alarm;
// You can also read the elapsed time
if overtemp && !alarm {
log::warn!(
"Temperature high for {:.1}s (alarm at 5.0s)",
self.fault_timer.et.as_secs_f64()
);
}
}
}
}
Ton behavior:
| Input | Duration | Timer State | Output (q) | Elapsed (et) |
|---|---|---|---|---|
false | any | Reset | false | 0 |
true | 3s | Counting | false | 0..3s |
true (after 3s) | 3s | Done | true | 3s |
false (any time) | any | Reset | false | 0 |
Combining Patterns: A Complete Machine Example
Here is a more realistic example that combines state machines, edge detection, and timers to control a simple pick-and-place machine:
project.json variables:
"variables": {
"cmd_start": { "type": "bool", "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:
- Runs as a separate process
- Connects to your Modbus TCP devices
- Cyclically reads and writes registers
- Maps register data into shared memory
Your control program reads and writes Modbus data through variables, just like any other I/O.
Configuring a Modbus Device
Add the Modbus module to your project.json:
{
"name": "modbus_example",
"version": "0.1.0",
"description": "Modbus TCP example",
"control": {
"enable": true,
"source_directory": "./control",
"entry_point": "main.rs",
"signals": {
"tick": {
"source": "internal",
"scan_rate_us": 10000
}
}
},
"modules": {
"modbus": {
"enabled": true,
"args": ["service"],
"config": {
"devices": [
{
"name": "sensor_unit",
"type": "modbus_tcp",
"host": "192.168.1.100",
"port": 502,
"slave_id": 1,
"scan_rate_ms": 100,
"registers": [
{
"name": "temperature",
"type": "input_register",
"address": 0,
"count": 1
},
{
"name": "humidity",
"type": "input_register",
"address": 1,
"count": 1
},
{
"name": "setpoint",
"type": "holding_register",
"address": 0,
"count": 1
}
]
}
]
}
}
},
"variables": {
"temperature_raw": {
"type": "u16",
"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:
| Field | Description |
|---|---|
modules.modbus.enabled | Set to true to enable the Modbus module |
modules.modbus.args | Must include "service" for the Modbus module |
config.devices[].name | A friendly name for the device (used in variable links) |
config.devices[].host | IP address of the Modbus TCP device |
config.devices[].port | TCP port (usually 502) |
config.devices[].slave_id | Modbus unit ID (1-247) |
config.devices[].registers[].type | "holding_register", "input_register", "coil", or "discrete_input" |
config.devices[].registers[].address | The register address (0-based) |
Linking Variables to Modbus Registers
The "link" field in a variable definition connects it to a Modbus register. The format is:
modbus.<device_name>.<register_name>
For example, if your device is named "sensor_unit" and the register is named "temperature", the link is "modbus.sensor_unit.temperature".
The link determines the data flow based on the Modbus register type:
| Register Type | Modbus 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:
- Scans and configures slaves on startup
- Exchanges PDO data cyclically (synchronized with the server tick)
- Maps all PDO entries into shared memory variables
Configuring EtherCAT Slaves
EtherCAT slaves are configured in the modules.ethercat.config.slaves array in project.json. Each slave needs:
- A name (used to build variable FQDNs)
- A position on the bus (0 = first slave)
- A device_id (vendor ID, product code, revision)
- Sync managers with PDO mappings
Here is a simple example with a Beckhoff EK1100 coupler and an EL1008 8-channel digital input module:
{
"modules": {
"ethercat": {
"enabled": true,
"args": ["service"],
"config": {
"interface_name": "eth0",
"auto_activate": true,
"runtime_settings": {
"cycle_time_us": 5000,
"priority": 99
},
"slaves": [
{
"name": "EK1100",
"position": 0,
"device_id": {
"vendor_id": 2,
"product_code": 72100946,
"revision_number": 1114112
}
},
{
"name": "DI_8CH",
"position": 1,
"device_id": {
"vendor_id": 2,
"product_code": 66084946,
"revision_number": 1048576
},
"sync_managers": [
{
"direction": "Inputs",
"index": 0,
"pdos": [
{
"name": "Channel 1-8",
"entries": [
{ "index": "0x6000", "sub": 1, "name": "Input 1", "type": "BIT", "bits": 1 },
{ "index": "0x6010", "sub": 1, "name": "Input 2", "type": "BIT", "bits": 1 },
{ "index": "0x6020", "sub": 1, "name": "Input 3", "type": "BIT", "bits": 1 },
{ "index": "0x6030", "sub": 1, "name": "Input 4", "type": "BIT", "bits": 1 },
{ "index": "0x6040", "sub": 1, "name": "Input 5", "type": "BIT", "bits": 1 },
{ "index": "0x6050", "sub": 1, "name": "Input 6", "type": "BIT", "bits": 1 },
{ "index": "0x6060", "sub": 1, "name": "Input 7", "type": "BIT", "bits": 1 },
{ "index": "0x6070", "sub": 1, "name": "Input 8", "type": "BIT", "bits": 1 }
]
}
]
}
]
}
]
}
}
},
"variables": {
"di_input_1": { "type": "bool", "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 slaveson a system with the IgH EtherCAT master installed.
Digital I/O Example
Using the EtherCAT digital input module above, here is a control program that reads the inputs and uses them:
#![allow(unused)]
fn main() {
use autocore_std::{ControlProgram, TickContext};
use autocore_std::fb::RTrig;
use crate::gm::GlobalMemory;
pub struct MyControlProgram {
start_trig: RTrig,
stop_trig: RTrig,
running: bool,
}
impl MyControlProgram {
pub fn new() -> Self {
Self {
start_trig: RTrig::new(),
stop_trig: RTrig::new(),
running: false,
}
}
}
impl ControlProgram for MyControlProgram {
type Memory = GlobalMemory;
fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
// Input 1 = Start button, Input 2 = Stop button
if self.start_trig.call(ctx.gm.di_input_1) {
log::info!("Start button pressed");
self.running = true;
}
if self.stop_trig.call(ctx.gm.di_input_2) {
log::info!("Stop button pressed");
self.running = false;
}
// Input 3 = Emergency stop (normally closed, active low)
if !ctx.gm.di_input_3 {
self.running = false;
}
// Input 4 = Part sensor
// Input 5 = Home position sensor
// etc.
}
}
}
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:
| What | SDO | Source |
|---|---|---|
| Sensitivity (mV/V) | 0x8000:0x23 | From the load cell’s calibration certificate |
| Full-scale load | 0x8000:0x24 | From the load cell’s datasheet (same units as load) |
| Scale factor | 0x8000:0x27 | EL3356 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’sbusy/errorfields. See the EL3356 FB reference. - As startup SDOs — add entries to the device’s
startup_sdoarray inproject.jsonso 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-axiscommand, the complete schema, drive-behavior defaults (including the AKD/Inovancehalt_blocks_setpoint_ackhalt-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:
sync()— copies TxPDO feedback from shared memory into the snapshot- Issue commands —
enable(),move_absolute(),home(), etc. 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"generatesLift). 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 theslavesarray). If you swap the motor to a different drive, changelink— the control program doesn’t need to change. -
options— sensor wiring, diagnostics, and motion settings (all optional):Field Type Default Description positive_limitstring — GlobalMemory bool for positive limit switch negative_limitstring — GlobalMemory bool for negative limit switch home_sensorstring — GlobalMemory bool for home reference sensor error_codestring — GlobalMemory u16 for drive error code maximum_pos_limitstring — GlobalMemory numeric (f32/f64/int) variable supplying a dynamic maximum software position limit, in user units minimum_pos_limitstring — GlobalMemory numeric variable supplying a dynamic minimum software position limit, in user units invert_directionbool falseNegate position targets and feedback (reverses motor direction in software) halt_blocks_setpoint_ackbool falseDrive trait: the drive won’t acknowledge a set-point while Halt is asserted, so the halt cancel handshake must clear halt. Set truefor the Kollmorgen AKD / Inovance SV660N. See Chapter 8b.soft_home_methodint 37CiA-402 homing method (0x6098) for “current position = home”. E.g. 35for 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:- Move rejection. Any
move_absoluteormove_relativewhose 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. - 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_limitand the correspondingminpair), 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 atNoneand falls back to the static config (or to no limit at all).Software limits only protect once the axis is homed —
position_actualis meaningless before homing — so wire these alongside your homing routine. - Move rejection. Any
-
outputs— axis status values published to GlobalMemory each tick (all optional, omit any you don’t need):Field GM type Description 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.jsonfile. Simply define your desired variable names in theoutputsandoptionsblocks, then runacctl 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 neutralmodules.motionhomes). (This is now part ofacctl codegen; the olderethercat.generate_variablescommand 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:
Liftis a DriveHandle auto-generated ingm.rs. It bundles theAxisstate machine with aCia402PpSnapshotthat holds PDO field copies by value. Fields not in the PDO mapping (likemodes_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()andtick(), issue commands freely:home(),enable(),move_absolute(),reset_faults(). - For software homing to a limit switch or sensor, specify the GlobalMemory variable names in
optionsin the axis config. The generatedsync()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.jsonconfiguration.
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
Axishelper 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
}
| Field | Meaning |
|---|---|
dc_enabled | Master runs DC setup for this slave and drives the bus time each cycle. |
dc_assign_activate | 16-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_ns | Sync0 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_ns | Phase offset of Sync0 relative to the start of the cycle. Leave at 0 unless the drive manual specifies otherwise. |
dc_sync1_cycle_ns | Sync1 period in nanoseconds. 0 disables Sync1. When non-zero, it is usually the same as dc_sync0_cycle_ns. |
dc_sync1_shift_ns | Phase 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=0x0300and 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
| Symptom | Likely 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 DC | dc_sync0_cycle_ns doesn’t match runtime_settings.cycle_time_us × 1000. |
| Drive enters OP but position jitter is high | dc_sync0_shift_ns may need tuning — consult the drive manual for recommended shift. |
| Only some DC slaves make it to OP | Usually 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:
| FQDN | Description |
|---|---|
ethercat.module.state | Module state: Idle, Configuring, Op |
ethercat.module.cycle_time | EtherCAT cycle time in microseconds |
ethercat.master.state | Master 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.
| FQDN | Type | Meaning |
|---|---|---|
ethercat.network_ready | bool | true 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_count | u32 | How many configured slaves are currently in OP. Lets the HMI show “3 / 8 slaves online” during bring-up. |
ethercat.slaves_total | u32 | Number of slaves configured in project.json. Constant once the module loads. |
ethercat.error | bool | Master 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 viactx.gm.ec_network_ready. - IPC broadcasts on transition + a ~5 s heartbeat. This is the path the
autocore consoleandacctl monitorsee, 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
| Command | Arguments | Description |
|---|---|---|
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_status | – | Per-slave AL state, error flags, and identity |
get_network_stats | – | Cycle time, link status, performance counters |
Device Definitions
| Command | Arguments | Description |
|---|---|---|
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
| Command | Arguments | Description |
|---|---|---|
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:
| Action | Extra Arguments | Description |
|---|---|---|
show | – | Show current device configuration |
list_profiles | – | List available PDO profiles |
select_profile | --profile (required) | Select a PDO profile |
list_modules | – | List 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_fqdns | – | List 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_dc | – | Disable distributed clocks for the slave |
SDO Access
| Command | Arguments | Description |
|---|---|---|
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
| Command | Arguments | Description |
|---|---|---|
activate | --project_file (optional) | Start the EtherCAT runtime |
stop | – | Stop the EtherCAT runtime |
get_status | – | Master connection and runtime status |
help | – | Show 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
AxisViewis generated PDO wiring), or - a virtual / simulated drive (the
AxisViewis an in-processSimDrive), 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:
| Location | For | Notes |
|---|---|---|
modules.ethercat.config.axes | EtherCAT-backed axes | The legacy/default home. Every existing project uses it. An untagged axis here is an EtherCAT axis. |
modules.motion.config.axes | Backend-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
| Flag | Default | Meaning |
|---|---|---|
--name | (required) | Axis name → the generated handle struct (e.g. Press). Describe the function, not the hardware. |
--link | — | Slave name to bind to. Required for ethercat, omitted for virtual. |
--type | pp | CiA-402 profile type (Profile Position). |
--backend | ethercat | ethercat 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.motionis a config-only namespace, not a runtime module — set"enabled": false. Codegen reads itsconfig.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-axissets this for you.
The axis schema
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Axis name → generated handle struct. |
link | string | ethercat only | Slave name this axis binds to (matches a slaves entry). |
type | string | yes | CiA-402 profile, currently "pp". |
backend | object | no | { "kind": "ethercat" } (default if omitted) or { "kind": "virtual" }. An optional link inside the ethercat backend overrides the top-level link. |
options | object | no | Sensor/limit wiring and drive-behavior traits — see below. |
outputs | object | no | Axis status values published to GlobalMemory each tick. |
options
| Field | Type | Default | Description |
|---|---|---|---|
positive_limit | string | — | GM bool for the positive limit switch |
negative_limit | string | — | GM bool for the negative limit switch |
home_sensor | string | — | GM bool for the home reference sensor |
error_code | string | — | GM u16 for the drive error code |
maximum_pos_limit | string | — | GM numeric: dynamic max software position limit (user units) |
minimum_pos_limit | string | — | GM numeric: dynamic min software position limit (user units) |
invert_direction | bool | false | Negate position targets and feedback (reverse direction in software) |
halt_blocks_setpoint_ack | bool | false | Drive trait. See Drive-behavior defaults. |
soft_home_method | int | 37 | CiA-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):
| Field | GM type | Field | GM type | |
|---|---|---|---|---|
position | f64 | in_motion | bool | |
raw_position | i64 | moving_positive | bool | |
speed | f64 | moving_negative | bool | |
is_busy | bool | at_max_limit | bool | |
is_error | bool | at_min_limit | bool | |
error_code | u32/i32 | at_positive_limit_switch | bool | |
error_message | string | at_negative_limit_switch | bool | |
motor_on | bool | home_sensor | bool |
You do not hand-create these variables. Name them in
outputs/options, andacctl codegenscaffolds 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 matchingoptionsintoproject.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_methodin the axisoptionsper 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 fixedSIM_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 codegenchecks 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 EtherCATDriveHandleor aSimDrive-backed handle), baking in theoptions(invert_direction,halt_blocks_setpoint_ack,soft_home_method). -
Scaffolds the axis variables. The
outputs/optionsvariable names are injected into the shared-memory layout automatically (via the server’sProject::normalize), for axes in both homes. You do not runethercat.generate_variablesfor 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). Ifcontrol/Cargo.lockpins an older version,acctl codegenstops before writinggm.rswith 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:
sync(gm)— pull feedback (EtherCAT: TxPDO from shared memory; virtual: advance the sim).- Issue commands —
enable(),move_absolute(pos, vel, accel, decel),move_relative(...),home(method),halt(),reset_faults(),set_position(...),disable(). 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
linkto the new slave name and re-runacctl codegen. The control program is untouched. - Change behavior — edit
optionsand re-runacctl codegen. Re-running the IDE seed command won’t clobber a value you set by hand. - Rename — changing
namerenames 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 fromvariablesif 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
| Symptom | Cause | Fix |
|---|---|---|
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 latches | Wrong CiA-402 homing method for the drive | Set soft_home_method (e.g. 35 for SV660N). |
gm.<axis var> not found when building | Axis output variable not scaffolded | Run 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 found | Control program’s autocore-std is older than the codegen output needs | Bump autocore-std (the acctl codegen gate prints the exact --precise version). |
| Move rejected / quick-stop near a limit | Dynamic software position limit tripped | Check 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:
new_projectcreates a minimal config with one empty task (1 kHz, 1000 sample buffer)add_channeladds a channel using thevoltagepreset. The--typeflag selects sensible defaults;--min_valand--max_valoverride specific parameters.save_configwrites the config into the currently loadedproject.json. On future server restarts, the module loads this config automatically.startbegins 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.
| Type | DAQmx Function | Description |
|---|---|---|
voltage | CreateAIVoltageChan | Analog voltage input |
strain_gage | CreateAIStrainGageChan | Strain gage (quarter/half/full bridge) |
accelerometer | CreateAIAccelChan | IEPE accelerometer |
force_bridge | CreateAIForceBridgeTwoPointLinChan | Force sensor via bridge two-point linear calibration |
force_iepe | CreateAIForceIEPEChan | IEPE force sensor (e.g. PCB impact hammer) |
linear_encoder | CreateCILinEncoderChan | Quadrature linear encoder (one per task) |
angular_encoder | CreateCIAngEncoderChan | Quadrature angular/rotary encoder (one per task) |
count_edges | CreateCICountEdgesChan | Counter edge counting |
frequency | CreateCIFreqChan | Frequency 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)
| FQDN | Type | Meaning |
|---|---|---|
ni.running | bool | The worker thread exists. Transitions to true on ni.start and back to false on ni.stop or if the worker exits unexpectedly. |
ni.hardware_ready | bool | DAQmx 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.error | bool | One 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)
| FQDN | Type | Meaning |
|---|---|---|
ni.<task>.actual_sample_rate | f64 | The 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_increment | f64 | Reciprocal 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.runningalone. A running worker whose tasks haven’t yet committed will silently droparmrequests — the trigger state machine won’t be initialized. Always gate on bothni.runningandni.hardware_readyand!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
| Command | Arguments | Description |
|---|---|---|
start | – | Start DAQmx acquisition |
stop | – | Stop DAQmx acquisition |
restart | – | Stop and restart (applies config changes) |
status | – | Show module status, channel values, and errors |
Configuration Inspection
| Command | Arguments | Description |
|---|---|---|
get_config | – | Show current in-memory configuration as JSON |
show_config | – | Alias for get_config |
describe | – | Human-readable summary of tasks, channels, DAQ, and timing |
list_tasks | – | List all tasks with channels and actual timing info (JSON) |
task | --name (required) | Show full configuration for a specific task (JSON) |
Project Lifecycle
| Command | Arguments | Description |
|---|---|---|
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
| Command | Arguments | Description |
|---|---|---|
add_task | --name, --sample_rate, --samples_per_channel, --samples_per_event (all required); --timeout_ms (default: 2500), --clock_type (default: “internal”), --clock_source, --parent_task | Add a new DAQmx task |
set_task | --name (required), plus any field --key value pairs | Update 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
| Command | Arguments | Description |
|---|---|---|
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_types | – | List available channel type presets with default parameters |
DAQ (Triggered Capture) Management
| Command | Arguments | Description |
|---|---|---|
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)
| Command | Arguments | Description |
|---|---|---|
discover_devices | – | Run 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 pairs | Update device fields (reserve, modules) |
generate_device_config | --path (optional), --import (default: false) | Generate .ini from config + nilsdev and optionally import |
Runtime Monitoring
| Command | Arguments | Description |
|---|---|---|
reset_minmax | --channel (optional, omit for all) | Reset min/max tracking |
list_devices | – | List connected NI DAQmx devices |
clear_errors | – | Clear the error log |
<channel> | – | Read all fields for a channel (value, min, max, rate, data_received) |
<channel>.value | – | Read only the current value (scalar f64) |
<channel>.min | – | Read only the running minimum |
<channel>.max | – | Read only the running maximum |
<channel>.rate | – | Read only the rate |
<daq>.arm | – | Arm a DAQ trigger |
<daq>.disarm | – | Disarm 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
| Command | Arguments | Description |
|---|---|---|
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:
| Hook | Purpose |
|---|---|
useAutoCoreConnection() | Connect to the server WebSocket |
useVariable(name) | Subscribe to a variable and get its live value |
useCommand() | Send commands to the server |
Creating a Simple Dashboard
Here is a basic HMI that shows the motor speed and provides start/stop controls. Edit www/src/App.tsx:
import React from 'react';
import { useVariable, useCommand } from './AutoCore';
import { Button } from 'primereact/button';
import { Knob } from 'primereact/knob';
function App() {
// Subscribe to live variable values
const motorRunning = useVariable<boolean>('machine_running');
const speedActual = useVariable<number>('motor_speed_actual');
const speedSetpoint = useVariable<number>('motor_speed_setpoint');
// Command hook for writing variables
const sendCommand = useCommand();
const handleStart = () => {
sendCommand('gm.write', { name: 'machine_running', value: true });
};
const handleStop = () => {
sendCommand('gm.write', { name: 'machine_running', value: false });
};
const handleSpeedChange = (rpm: number) => {
sendCommand('gm.write', { name: 'motor_speed_setpoint', value: rpm });
};
return (
<div style={{ padding: '2rem' }}>
<h1>Motor Control</h1>
<div style={{ display: 'flex', gap: '2rem', alignItems: 'center' }}>
<div>
<h3>Speed</h3>
<Knob
value={speedActual?.value ?? 0}
max={2000}
readOnly
valueTemplate="{value} RPM"
size={150}
/>
</div>
<div>
<h3>Setpoint</h3>
<Knob
value={speedSetpoint?.value ?? 0}
max={2000}
onChange={(e) => handleSpeedChange(e.value)}
valueTemplate="{value} RPM"
size={150}
/>
</div>
<div>
<h3>Controls</h3>
<Button
label="Start"
icon="pi pi-play"
onClick={handleStart}
disabled={motorRunning?.value === true}
severity="success"
style={{ marginRight: '1rem' }}
/>
<Button
label="Stop"
icon="pi pi-stop"
onClick={handleStop}
disabled={motorRunning?.value !== true}
severity="danger"
/>
</div>
</div>
<p>
Status: {motorRunning?.value ? 'RUNNING' : 'STOPPED'}
</p>
</div>
);
}
export default App;
Subscribing to Live Variable Updates
When you use useVariable(name), the library automatically subscribes to that variable via WebSocket. The value updates in real time whenever the control program changes it — there is no polling.
Under the hood, 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.Controloperations likelist_files,create_group,delete; or application-level topics on ADS/MODBUS/PYTHON modules that still route by legacyfnamestrings).
// 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:
| Provider | Source | Provides |
|---|---|---|
EventEmitterProvider | core/EventEmitterContext | The invoke, subscribe, unsubscribe, dispatch, write primitives that every other provider builds on. |
PrimeReactProvider | primereact/api | PrimeReact’s own context (locale, ripple, etc.). |
AutoCoreTagProvider | core/AutoCoreTagContext | GM tag registry, type-aware reads/writes, scale conversions. Backs useAutoCoreTag(). |
TisProvider | components/tis/TisProvider | Test Information System — schemas, selection (project/method/sample/run), staged config draft, run cache. Backs every TIS component. |
AmsProvider | components/ams/AmsProvider | Asset 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.
| Component | Purpose |
|---|---|
<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.
| Component | Purpose |
|---|---|
<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
| Component | Purpose |
|---|---|
<IndicatorButton /> | Two-state colored button (motor on/off, error reset, etc.). |
<IndicatorColor> enum | Standard 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_nameplus 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:
| Array | Lifecycle | One row per |
|---|---|---|
project_fields | Filled once by the HMI when the method is selected. Not cycled. | Test record (top of test.json) |
config_fields | Operator-input speeds, loads, surface IDs. Snapshotted at start_test. | Test record (test.json::config) |
cycle_fields | Per-cycle measurements. Written by the control program each cycle. | Cycle (cycles.jsonl line) |
results_fields | Post-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.
- Type —
string,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
namewhen 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 for1.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. - Type —
cycle_scatter(one point per cycle, plotted by<TestDataView>) orraw_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 | rightselector 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 totrace. Files end up asraw_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 fromproject.json’smodules.ni.config.daq[<daq>].channelsarray.derived— computed at codegen time from other declared columns via the formula field. Supports+ - * /withabs(...),sqrt(...), and a top-levelddt(<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_schemasif AmsProvider is upstream. Falls back to a free-form text input otherwise. - Select —
by_location(asset whose location matches a fixed value) orby_id_field(asset whose ID is read from a config field at start-time). - Location or From — surfaces conditionally based on the Select
value.
Locationis an AMS location string (e.g.,tsdr);Fromis a dotted config path (e.g.,config.surface_asset_id). - Calibration policy —
ignore/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_methodexactly. 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.
viewsaxes reference known field or column names.raw_data.blob_nameis 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.columnssources reference real NI DAQ channels.analysis.scriptexists on disk.asset_refs.asset_typeexists 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:
- Fetches a fresh disk-state via
tis.list_schemas(the read endpoint that bypasses staging). - Compares to the current staged methods.
- Shows a summary: Added / Removed / Modified by method ID.
- 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?”).
- On Confirm, calls
tis.save_config. The server writesproject.json.bak, then atomically replacesproject.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:
| Action | Server check | What you see |
|---|---|---|
tis.put_method | The 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_method | Same. | Same. |
tis.save_config | ANY 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:
| Topic | Request data | Response data | Side 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:
type | Component that renders it | x/y refer to | Best for |
|---|---|---|---|
cycle_scatter | <TestDataView /> main panel | cycle_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:
| Class | Effect |
|---|---|
.ac-form-section | Spans all columns. Renders as a small uppercase header with an underline. Use for “Test Configuration”, “Network Settings”, section dividers. |
.ac-form-wide | Spans all columns. No styling — for content that shouldn’t look like a section header. |
.ac-form-span | Spans 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).
| Class | Size | Use for |
|---|---|---|
.ac-toolbar-icon-btn | ~19mm × 12.7mm (compact) | Standard toolbar action buttons |
.ac-toolbar-icon-lg | ~25.4mm × 19mm | Larger toolbar actions where touch target matters |
.ac-toolbar-icon-panic | 25.4mm × 25.4mm | The PANIC button specifically — squarer, larger, severity=danger |
.ac-toolbar-group | flex row, gap: 2px | Group of related buttons (motor on/off cluster, etc.) |
.ac-toolbar-tool-list | flex column | Stacked text-button list inside an OverlayPanel |
.ac-toolbar-tool-item | left-justified | Single 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:
- Create a project-local SCSS file.
- Import it after the autocore-react theme so your rules win cascade ties (or use higher specificity).
- Match PrimeReact’s selector specificity when overriding their
components —
.p-dialog .p-dialog-contentbeats.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:
| Hook | From | Returns |
|---|---|---|
useAutoCoreTag(name) | AutoCoreTagProvider | { value, write, tap } for a GM tag |
useTis() | TisProvider | full TIS context (selection, schemas, stagedConfig, …) |
useTisSelection() | TisProvider | [selection, setSelection] tuple |
useTisSchemas() | TisProvider | the test method registry |
useAms() | AmsProvider | full AMS context |
useAmsSchemas() | AmsProvider | the asset_type registry |
useAmsRoles() | AmsProvider | roles per asset_type (from project.json’s by_location asset_refs) |
useAmsAssets() | AmsProvider | the 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:
| Topic | Module | Command |
|---|---|---|
labelit.status | labelit | status |
modbus.read_holding | modbus | read_holding |
python.run_script | python | run_script |
system.full_shutdown | system | full_shutdown |
system.cancel_full_shutdown | system | cancel_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
| Command | Description |
|---|---|
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> --list | List 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:
| Command | Description |
|---|---|
acctl add-tis | Adds 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-ams | Adds 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
| Command | Description |
|---|---|
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
| Command | Description |
|---|---|
acctl info | Show a human-readable project summary (modules, variables, control program, www status) |
acctl validate | Check 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 status | Show 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
| Command | Description |
|---|---|
acctl control start | Start the control program |
acctl control stop | Stop the control program |
acctl control restart | Restart the control program |
acctl control status | Show control program state and PID |
Monitoring
| Command | Description |
|---|---|
acctl status | Server status, control program state, project list |
acctl logs | Show recent control program log output |
acctl logs --follow | Stream 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
| Command | Description |
|---|---|
acctl codegen | Regenerate 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 sync | Compare 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 all | Same 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 withacctl 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 sharedproject.jsonis 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 withacctl 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 type | TS valueType |
|---|---|
bool | "boolean" |
u8–u64, i8–i64, 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_position → liftAxisPosition). 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:
| Situation | Action |
|---|---|
www/src/AutoCoreTags.ts doesn’t exist | Write full file from template. |
File exists, has both sentinel comments and acTagSpecCustom | Replace only the generated block; acTagSpecCustom is preserved. |
File exists but missing a sentinel or acTagSpecCustom | Full rewrite from template. Old file is saved to AutoCoreTags.ts.bak. |
--force is passed | Full 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
| Command | Description |
|---|---|
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-vars | Find 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.
| Command | Flags | Description |
|---|---|---|
acctl doc init | --force | Scaffold 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 build | — | Build 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-vars | — | Regenerate doc/src/variables.md from project.json (hardware-linked / bit-mapped / plain tables). |
acctl doc clean | — | Remove 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:
-
Push to the server —
acctl push docbuilds 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 (default4444, configurable inconfig.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. -
Zip and share —
doc/book/is fully self-contained. Zip it, email it, or drop it on a shared drive. Recipients unzip and openindex.htmldirectly from the filesystem. -
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
| Command | Description |
|---|---|
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.
| Command | Description |
|---|---|
acctl tools list | List registered tools with running state, URL, and the module domains each edits |
acctl tools rescan | Re-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 docsupport anddoc/book.tomldoesn’t exist, runacctl doc initfrom the project root. It scaffolds the same five files thatacctl newwould have produced, pulling the project name fromproject.jsonfor the book title. Existing files are left untouched; pass--forceto 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 inproject.json’svariablesmap. Three sections are emitted when non-empty: Hardware-Linked (entries with alinkfield), Bit-Mapped (entries withsource+bit), and Other. Columns include FQDN, type, description, and the relevant linkage fields.doc/src/rustdoc/— a copy ofcargo doc --no-depsoutput fromcontrol/. The defaultcontrol_api.mdchapter links intorustdoc/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 openindex.htmldirectly. - 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.
- Allocation: When the server starts, it creates a shared memory segment called
autocore_cyclicbased on the variables inproject.json. - Mapping: The control program and all enabled modules map this segment into their own address space.
- Synchronization: The server generates a tick event. The control program waits for this event, reads the memory, processes one cycle, and writes back.
This zero-copy architecture means that I/O data exchange takes nanoseconds, not milliseconds.
The Module System
External modules extend AutoCore’s hardware capabilities. Each module:
- Is spawned as a child process by the server on startup
- Receives three CLI arguments:
--ipc-address,--module-name, and--config - Connects to the server’s IPC port (default 9100)
- Receives lifecycle commands:
initialize,configure_shm,finalize - Maps shared memory variables to exchange cyclic data
- Handles commands routed by the server based on the module’s domain name
Built-in modules:
- autocore-ethercat: EtherCAT fieldbus master
- autocore-modbus: Modbus TCP client
- autocore-labelit: Camera and label inspection
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 inproject.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 markedFailed. All three CLI args are passed, including--config <resolved-json>. - Ad-hoc (
system.load_module). Bootstraps a module that is registered inconfig.ini::[modules]but NOT declared inproject.json. Only two CLI args are passed:--ipc-addressand--module-name. No--config— by design, because there’s nothing inproject.jsonto 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 validateagainst 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
--configflag when running the server
[console]
port = 11969 # WebSocket port for CLI and web clients
www_root = /srv/autocore/console/dist # Path to web console static files
[general]
projects_directory = /srv/autocore/projects # Root directory for all projects
module_base_directory = /opt/autocore/bin/modules # Directory containing module executables
port = 8080 # HTTP port for the web server
autocore_std_directory = /srv/autocore/lib/autocore-std # Path to the autocore-std library
disable_ads = 1 # Disable TwinCAT ADS compatibility
ipc_port = 9100 # TCP port for module IPC
project_name = default # Project to load on startup
[modules]
modbus = ${general.module_base_directory}/autocore-modbus
ethercat = ${general.module_base_directory}/autocore-ethercat
labelit = ${general.module_base_directory}/autocore-labelit
The [modules] section maps module names to executable paths. This keeps project.json portable — the same project file works on different machines where modules may be installed in different locations.
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": ""
}
| Field | Type | Description |
|---|---|---|
transaction_id | number | Unique ID for matching responses to requests. The server echoes this back. For broadcasts, this is 0. |
timecode | number | Timestamp in milliseconds since UNIX epoch. |
topic | string | The 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_type | number | The operation to perform (see table below). |
data | any | The payload. For a Write, this is the value to set. For a Read Response, this is the value retrieved. |
crc | number | Optional CRC32 checksum for message integrity verification. Defaults to 0. |
success | boolean | true if the operation succeeded, false if it failed. Only meaningful in responses. |
error_message | string | Human-readable error description if success is false. Otherwise empty. |
Message Types
| Name | Value | Description |
|---|---|---|
| NoOp | 0 | No operation. Used for connection testing / ping. |
| Response | 1 | Reply to a previous request. The transaction_id matches the original. |
| Read | 2 | Request to read the current value of topic. |
| Write | 3 | Request to update the value of topic. |
| Subscribe | 4 | Request to receive updates whenever topic changes. |
| Unsubscribe | 5 | Stop receiving updates for topic. |
| Broadcast | 6 | Unsolicited push from server to client (live variable update). |
| Heartbeat | 7 | Keepalive signal. |
| Control | 8 | System control message (initialize, finalize, configure). |
| Request | 10 | Generic 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:
| Domain | Routes To | Example Topics |
|---|---|---|
gm | Global Memory servelet | gm.motor_speed, gm.cycle_counter |
system | System servelet | system.get_domains, system.new_project, system.full_shutdown |
datastore | Datastore servelet | datastore.calibration.offset |
modbus | Modbus module | modbus.vfd_01.speed_setpoint |
ethercat | EtherCAT module | ethercat.clearpath_0.rxpdo_5.controlword |
python | Python servelet | python.run_script |
Glossary
| Term | Definition |
|---|---|
| FQDN | Fully Qualified Domain Name. A dot-separated hierarchical address for any resource in the system. Example: ethercat.servo_drive.rxpdo_1.controlword |
| PDO | Process Data Object. The cyclic data image exchanged with fieldbus devices every scan cycle. |
| SDO | Service Data Object. A request/response protocol for reading or writing individual configuration parameters from a device. Used for acyclic (on-demand) access. |
| Cyclic data | Data exchanged at a fixed interval (every tick). PDO data from EtherCAT slaves is cyclic. Requires deterministic timing. |
| Acyclic data | Data exchanged on demand or at variable intervals. Modbus register reads, SDO access, and CommandMessage requests are acyclic. |
| Process image | The complete set of input and output data for all devices on a fieldbus, updated each scan cycle. |
| Scan cycle | One complete exchange of process data with all fieldbus devices. At a 1 ms cycle time, there are 1,000 scan cycles per second. |
| Servelet | An 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
ModuleHandlercommunicates with the camera thread through channels. - IPC commands map to subtopics:
camera_start,camera_snap,camera_shutdown,status. - Timeouts on every operation: Each camera operation is wrapped in
tokio::time::timeoutso a stuck camera cannot hang the IPC loop. - Graceful lifecycle: The camera worker is spawned in
on_initialize()and shut down inon_finalize().
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-serversupervise long-running tools (start them with the server, stop them on shutdown, restart them on demand);autocore-idediscover 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
| Module | Tool | |
|---|---|---|
| Connects to the server over IPC | Yes | No |
| Receives its config on the command line | Yes (--config) | No |
| Participates in the control loop | Yes | No |
| Typically a… | hardware/service driver | web UI / editor / utility |
| Discovered from | project.json modules | the tool registry (tools.d/) |
| Examples | modbus, ethercat, ni, labelit | labelit-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:
- 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. - 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:
| Scope | Lives in | Who writes it |
|---|---|---|
| Module operational config | project.json → modules.<name>.config | the editor / the IDE |
| Tool manifest | tools.d/<tool>.json | the installing package |
| Tool settings | tool-settings/<tool>.json | admin / 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" }
]
}
| Field | Meaning |
|---|---|
name | Unique tool id (kebab-case); must match the file stem. |
executable | Absolute path to the binary. |
serves_http / ui_path | Whether it serves a web UI, and the root path. Drives the security gate and lets a browser/IDE point at it. |
launch.mode | service (long-running; the server supervises it) or on_demand (invoked by the IDE/CLI; never auto-started). |
launch.default_port | Default HTTP port; the effective port comes from settings. |
launch.autostart | Default for starting with the server; effective value comes from settings. |
launch.args | Extra 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_httptool is an unauthenticated surface (it can edit config and, on a target, trigger the camera). Until AutoCore adds user authentication,binddefaults to127.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):
| Command | Effect |
|---|---|
system.list_tools | List registered tools with live running state and URL. Used by the IDE to discover editors. |
system.rescan_tools | Re-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:
-
Ship the manifest as a packaged file into
tools.d/. InCargo.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.
-
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
acctlis 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
camerafeature and is dropped with--no-default-features. - A minimal static OpenCV removes the
libopencvruntime 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:
- Build the tool as a standalone HTTP server that accepts
--portand--bind. (It can be anything — the registry only cares about the manifest.) - Write a manifest declaring
serves_http: true,launch.mode: "service", and aneditorsentry whosetarget_domainis the module domain it edits. - Ship it in the tool’s package: the binary plus the manifest as a
dpkg-owned file in
tools.d/, and apostinst/postrmrescan poke. - (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:
| Level | User-facing name | Wire/code key | What it is |
|---|---|---|---|
| 1 | Project | project_id | The customer, contract, or product line. Owns many tests. |
| 2 | Test Method | method_id | The standardised recipe: schema, chart views, validation rules. Defined in project.json under test_methods. |
| 3 | Sample | sample_id | The physical object in the machine right now. Operator types it on the setup form; required. |
| 4 | Test Record | run_id | A 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:
-
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)" } ] } } } } } -
Generate the typed code:
acctl codegen-tagsThis regenerates
control/src/gm.rs(withTestInformationSystemplus one*TestManagerper method) andwww/src/autocore/tis.ts(with one*Schemaper method). -
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); } } } -
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 successfulstart_testdoes NOT consume it. The operator can run sample after sample by changing onlysample_idand clicking Start. Cancel viatis.clear_staged. -
tis.start_test— Operator (or the control program) opens a record on disk. Server createstest.json+ the emptycycles.jsonl+raw_data/+filtered_data/directories. Setstis.active = trueand broadcasts the fourtis.active_*scalars. The control program’stick_with_autostartdoes this for you. -
tis.finish_test— Closes the record. Flipstis.active = false. The control program’send_active(ctx)calls this.
Auto-injected GM scalars (added by Project::normalize() at server
load when test_methods is non-empty):
| GM variable | Linked to | Type |
|---|---|---|
tis_staged | tis.staged | bool |
tis_staged_project_id | tis.staged_project_id | string |
tis_staged_method_id | tis.staged_method_id | string |
tis_staged_sample_id | tis.staged_sample_id | string |
tis_active | tis.active | bool |
tis_active_project_id | tis.active_project_id | string |
tis_active_method_id | tis.active_method_id | string |
tis_active_sample_id | tis.active_sample_id | string |
tis_active_run_id | tis.active_run_id | string |
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:
| Raw | Filtered |
|---|---|
tis.add_raw_data | tis.add_filtered_data |
tis.read_raw | tis.read_filtered |
tis.list_raw | tis.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 fromdata.actual_samplesanddata.sample_rate(no DAQ channel needed)."ni.<daq>.channels.<chan>"— looks up the channel index inproject.json’smodules.ni.config.daq[<daq>].channelsarray and emits a typed pull fromDaqCapture.
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
| Form | Meaning |
|---|---|
<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 asif rhs.abs() > f32::EPSILON { lhs / rhs } else { 0.0 }, so traces don’t go toNaN/Infat 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.
| Method | Returns | Purpose |
|---|---|---|
new() | Self | Construct. 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) | bool | A trace is being built or sent. |
record_raw_trace_is_error(&self) | bool | Most recent trace ended in error. |
record_raw_trace_error_message(&self) | &str | Diagnostic for the last error, or empty string. |
run_analysis(&mut self, ctx) | AnalysisDispatch | Fire the active method’s Python analysis script. |
is_analysis_busy(&self) | bool | An analysis request is in flight. |
is_analysis_error(&self) | bool | Most recent analysis failed. |
analysis_error_message(&self) | &str | Diagnostic 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:
| Method | Returns | Purpose |
|---|---|---|
new() | Self | Construct. |
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_message | as above | Per-method versions. |
run_analysis(&mut self, ctx) | AnalysisDispatch | Per-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.
| Topic | Caller | Section |
|---|---|---|
| Lifecycle | ||
tis.stage_test | HMI | Lifecycle |
tis.clear_staged | HMI | Lifecycle |
tis.start_test | Control | Lifecycle |
tis.finish_test | Control | Lifecycle |
tis.status | Either | Lifecycle |
| Cycles and traces | ||
tis.add_cycle | Control | Cycles and traces |
tis.update_results | Control | Cycles and traces |
tis.add_raw_data | Control | Cycles and traces |
tis.add_filtered_data | Post-process | Cycles and traces |
| Reading runs | ||
tis.list_tests | HMI | Reading runs |
tis.read_test | HMI | Reading runs |
tis.read_cycles | HMI | Reading runs |
tis.list_raw | HMI | Reading runs |
tis.read_raw | HMI | Reading runs |
tis.list_filtered | HMI | Reading runs |
tis.read_filtered | HMI | Reading runs |
| Projects | ||
tis.list_projects | HMI | Project management |
tis.create_project | HMI | Project management |
tis.read_project | HMI | Project management |
tis.update_project | HMI | Project management |
tis.delete_project | HMI | Project management |
tis.list_methods | HMI | Project management |
tis.list_schemas | HMI | Project management |
| Admin and exports | ||
tis.delete_test | HMI | Admin and exports |
tis.disk_usage | HMI | Admin and exports |
tis.export_test_csv | HMI | Admin and exports |
tis.export_test_data_csv | HMI | Admin and exports |
tis.export_project_csv | HMI | Admin and exports |
tis.export_project_zip | HMI | Admin 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)
| Topic | Fires when | Payload |
|---|---|---|
tis.staged | stage_test, clear_staged | bool (scalar) |
tis.staged_project_id | stage_test | string |
tis.staged_method_id | stage_test | string |
tis.staged_sample_id | stage_test | string |
tis.active | start_test, finish_test | bool (scalar) |
tis.active_project_id | start_test, finish_test | string |
tis.active_method_id | start_test, finish_test | string |
tis.active_sample_id | start_test, finish_test | string |
tis.active_run_id | start_test, finish_test | string |
tis.last_start_error | start_test succeeds (clears) / fails (sets) | string |
tis.cycle_added | add_cycle succeeds | { project_id, method_id, run_id, cycle } |
tis.results_updated | update_results succeeds | { project_id, method_id, run_id, results } |
tis.status | stage_test | full staged record (diagnostic) |
tis.project_created | create_project succeeds | { project_id } |
tis.project_updated | update_project succeeds | { project_id } |
tis.project_deleted | delete_project succeeds | { project_id } |
tis.test_deleted | delete_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 ontis.active = falseand 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 thedownload_url. - For administration (deleting a misfired test, freeing disk),
the Project Manager tab calls
tis.delete_test,tis.delete_project, and readstis.disk_usage.
Where each piece is documented
| Step | Topic / API | Section |
|---|---|---|
| Stage form | tis.stage_test | Lifecycle |
| Autostart | TestInformationSystem::tick_with_autostart | Control program API |
| Cycle row | TestInformationSystem::record_cycle → tis.add_cycle | Control program API, Cycles and traces |
| Raw trace | TestInformationSystem::record_raw_trace → tis.add_raw_data | Control program API, Cycles and traces |
| Results | TestManager::update_results → tis.update_results | Control program API, Cycles and traces |
| Finish | TestInformationSystem::end_active → tis.finish_test | Control program API, Lifecycle |
| Analysis | TestInformationSystem::run_analysis | Control program API |
| Filtered data | tis.add_filtered_data | Cycles and traces |
| Exports / admin | tis.export_*, tis.delete_*, tis.disk_usage | Admin 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).
| Component | Default 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 12 —
acctl new-tis-project,acctl add-tis, andacctl codegen-tags. - Chapter 16 — Asset Management System
(
ams.*RPCs, calibration history, surface lanes, theasset_refs/asset_snapshotintegration).
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:
- Schema Definition: All test structures are defined in
project.json. - Code Generation: Auto-generates typed Rust structs and TypeScript interfaces.
- Real-Time Collection: The control program pushes cycle data via IPC (non-blocking).
- Asynchronous Storage: A dedicated servelet handles disk I/O, UTC timestamping, and checksumming.
- Filesystem-Based: Data is stored as standard JSON and JSONL files for maximum portability.
Auto-provided fields in the legacy contract:
| Field | Description |
|---|---|
test_id | ISO-8601 timestamp string assigned on results.start_test. Becomes the directory name under datastore/results/<project_id>/<definition_id>/. |
created_at | UTC timestamp set when the test record is first created. |
completed_at | UTC timestamp set when the test is closed. |
checksum | SHA-256 of the final test.json payload. |
schema | Snapshot of the definition used. |
project_id | Supplied 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_id → method_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:
- What’s installed — a registry of every asset, identified by a
server-generated
asset_id. - What state it’s in — the active calibration values plus a full audit-trail of past calibrations.
- 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:
| Level | User-facing name | Wire/code key | What it is |
|---|---|---|---|
| 1 | Asset Type | asset_type | The shape of the equipment — built-in (load_cell, linear_encoder, spring) or custom (surface, etc.). Defined in code or in project.json::asset_types. |
| 2 | Asset | asset_id | One physical instance, with a serial number and install location. Server-generated identifier. |
| 3 | Calibration Record | cal_id | One 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 type | Default prefix |
|---|---|
load_cell | LC- |
linear_encoder | ENC- |
spring | SP- |
| Custom types | A- (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 field | Type | Required | Notes |
|---|---|---|---|
capacity | f32 | yes | Full-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_units | string | yes | Engineering 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_v | f32 | yes | Nameplate output at full compressive load, in mV/V. Feeds NI bridge prescaled_max and EL3356 0x8000:23. |
tension_sensitivity_mv_v | f32 | no | Nameplate 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_ohm | f32 | yes | Wheatstone bridge resistance, in Ω. Feeds NI nominal_bridge_resistance. |
excitation_v | f32 | no | Data-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 field | Type | Required | Notes |
|---|---|---|---|
scale | f32 | yes | Counts-to-engineering-units multiplier. Output is in the cell’s nameplate capacity_units. |
offset | f32 | yes | Zero-load counts. |
range | f32 | no | Optional 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 — ifcapacity_units≠"N", the consumer is responsible for converting before writing0x8000: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 field | Type | Notes |
|---|---|---|
counts_per_mm | f32 | Required. |
offset_mm | f32 | Required. Zero-position offset. |
direction | string | "+" 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 field | Type | Notes |
|---|---|---|
stiffness_n_per_mm | f32 | Required. |
free_length_mm | f32 | Required. |
preload_n | f32 | Optional. |
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"]
}
| Value | Effect |
|---|---|
| omitted / absent | All 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:
Project::normalize()injects the baseline AMS GM scalars (ams_asset_count,ams_alert_calibration_overdue,ams_alert_lane_unavailable).- The next
acctl codegenregeneratescontrol/src/gm.rswith typed calibration structs (e.g.,LoadCellCalibrationwithscale: f32, offset: f32) and writeswww/src/autocore/ams.tswith mirrored TypeScript types and the asset_type catalog. - 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 (lanes → lane)
and appending _NN. Use a singular name if your equipment doesn’t
pluralize naturally (channel → channel_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 againstasset.custom.<field>(the nameplate values declared by the asset_type’sfields) or one of the top-levelAssetkeys (asset_id,serial,location,install_date,current_calibration_id).cal.<field>reads from the asset’s active calibration’svalues.<field>.sub.<key>.<field>andcal.<key>.<field>are the per-axis forms used by multi-axis types — they index intoasset.sub_locations.<key>.<field>andcalibration.values.<key>.<field>respectively.
Render modifiers (chain left-to-right with |):
| hex<bits>— zero-padded uppercase hex withbits/4digits (bitsmust be a multiple of 8 — matches the EtherCAT SDOvalueparser, which is byte-width-padded). Negative values and floats are rejected; overflow is rejected.| neg— flip the sign. Useful for paired bridgemin_val/max_valwheremin = -capacity.| default <json_literal>— bring-up escape hatch. When the lookup would fail (no asset, no current calibration, field absent, value isnull), substitute the literal value instead of hard-erroring. Argument is a JSON literal so numbers, quoted strings, booleans, andnullall 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:
- Live asset value (if registered, field set, and not
null). - Inline
| default <json>modifier on the placeholder, if specified. - Schema-level
defaultdeclared on the asset_type field definition. - 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 text | Substituted value |
|---|---|
default 5000 | the number 5000 |
default -500 | the number -500 |
default 3.14 | the number 3.14 |
default "Interface" | the string Interface |
default true | the boolean true |
default null | JSON 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:
-
At project push (
acctl sync):ams.placeholderfindings 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. -
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’sstart_modulereturns a typedSupervisorError::UnresolvedAmsPlaceholderslisting 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 variable | Type | Source |
|---|---|---|
ams_active_<field>_asset_id | string | tis.start_test (test-time view) |
ams_active_<field>_calibration_id | string | tis.start_test (test-time view) |
ams_active_<field>_present | bool | AMS (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_usagezeros the counters and appends an entry tousage_log.jsonlnext tousage.jsonso 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 intotest.json::asset_snapshot, and the suffix on the auto-injectedams.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 whoselocationmatcheslocationbelow.by_id_field— read the asset_id directly from a config field (dottedfrompath 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_idams_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:
- Placeholder resolution. When
${ams.by_location.<location>.<field>}resolves and no live asset value is on disk (and the placeholder has no| default Xmodifier), the asset_ref’s default for<field>is substituted. This works on built-in asset_types too —load_celland friends have no schema-leveldefaultslot, so the asset_ref is the per-role hook for declaring expected nameplate values. - AIS form pre-fill. When the operator opens the create-asset
form for this role, AIS seeds the nameplate inputs from the
defaultsmap. 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>
);
}
| Component | Responsibility |
|---|---|
<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 topic | What it counts |
|---|---|
ams.asset_count | Number of active assets in the registry. |
ams.alert_calibration_overdue | Active assets whose current calibration’s expires_at is in the past. |
ams.alert_lane_unavailable | Sub_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_snapshotstill resolves correctly after a restore.
Deleting an asset
Assets can be permanently removed from AMS in a two-step flow:
-
Retire.
ams.update_assetwithstatus: "retired". The asset stays on disk but is excluded from active queries, the placeholder resolver, and anyby_locationsnapshot. The AIS UI surfaces a Retire button on<AssetDetailView>for active assets. -
Delete.
ams.delete_assetis 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_deletedwith{ asset_id, asset_type }(subscribers can filter on type if they care)ams.asset_count(the alert counter recomputes)ams.active_<field>_presentis re-published for every role whose location lost an asset (so control programs’_presentGM 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”:
- Run
acctl add-amsto enable the subsystem. - Use
<AssetRegistryTable>’s “Add Asset” dialog (or theams.create_assetRPC directly) to register each piece of equipment. Record the manufacturer serial under theserialfield for traceability. - 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. - Update each test method’s
asset_refsblock to point at the new asset locations. Runacctl codegento 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:
- 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.
- 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.
- 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:), theautocore-pythonprocess will get stuck. Your control program handles this safely (it just stays in theCalculatingstate 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()orsum()to prevent unhandled script crashes. - Use
custom_paramsfor 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 thecustom_paramsblock 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:
- Is the server running?
sudo systemctl status autocore_server - Is there a build error? Check the output of
acctl push controlfor Rust compiler errors. - Is the project loaded?
acctl statusshould show your project as active.
Tick signal lost
Symptom: Control program starts but does not cycle. Logs show “Tick wait failed”.
Fix: The server should auto-reset timers. If not, restart the server:
sudo systemctl restart autocore_server
Variables not updating in the HMI
Check:
- Is the control program running?
acctl control status - Is the variable being written in
process_tick? Add a log statement to verify. - Is the WebSocket connected? Check the browser console for connection errors.
Hardware not responding (EtherCAT/Modbus)
Check:
- Is the module enabled in
project.json? Check"enabled": true. - Is the module executable configured in
config.ini? - Is the module running?
acctl cmd system.get_domainslists all connected modules. - Check module logs:
acctl logs --followwill show output from all modules. - For EtherCAT: Is the network cable connected? Is the correct interface configured?
- For Modbus: Can you ping the device? Is the IP address and port correct?
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:
- Run
acctl validate. If it reports anyams.placeholdererrors, 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. - Check the server log at
/srv/autocore/logs/autocore-server.logfor anUnresolvedAmsPlaceholdersline. That’s the supervisor refusing to start the module — the validator above should have caught it, but the log gives the exact placeholder path. - Confirm the module’s actual launch command with
ps -ef | grep <module-name>. If the row has only--ipc-addressand--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. - After fixing the project, run
acctl push project --restartto 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
| Command | What It Shows |
|---|---|
acctl status | Server version, active project, available projects |
acctl control status | Control program state (running/stopped/error) |
acctl logs --follow | Live log stream from control program and modules |
acctl cmd system.get_domains | All registered domains (modules, services) |
acctl cmd system.full_shutdown | Schedule a full PC shutdown (15 s delay) |
acctl cmd system.cancel_full_shutdown | Cancel a pending full shutdown |
acctl cmd gm.read --name <var> | Current value of a variable |
sudo systemctl status autocore_server | Server process status |
sudo journalctl -u autocore_server -f | Server system log (systemd) |
Appendix A: Variable Type Reference
Scalar Types
| Type | Rust Type | Size | Range | IEC 61131-3 Equivalent |
|---|---|---|---|---|
bool | bool | 1 byte | true / false | BOOL |
u8 | u8 | 1 byte | 0 to 255 | USINT / BYTE |
i8 | i8 | 1 byte | -128 to 127 | SINT |
u16 | u16 | 2 bytes | 0 to 65,535 | UINT / WORD |
i16 | i16 | 2 bytes | -32,768 to 32,767 | INT |
u32 | u32 | 4 bytes | 0 to 4,294,967,295 | UDINT / DWORD |
i32 | i32 | 4 bytes | -2,147,483,648 to 2,147,483,647 | DINT |
u64 | u64 | 8 bytes | 0 to 2^64 - 1 | ULINT / LWORD |
i64 | i64 | 8 bytes | -2^63 to 2^63 - 1 | LINT |
f32 | f32 | 4 bytes | IEEE 754 single precision | REAL |
f64 | f64 | 8 bytes | IEEE 754 double precision | LREAL |
String Type
| Type | Rust Type | Size | Range |
|---|---|---|---|
string | FixedString<N> | N bytes | UTF-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:
- After reading shared memory:
unpack_bits()extracts each bool from its source word - Your
process_tick()runs — read and write the individual bools naturally - Before writing shared memory:
pack_bits()inserts each bool back into its source word
Unmapped bits in the source word are preserved.
Rules:
sourcemust name another variable in the same projectsourcemust be an integer type (u8,u16,u32,u64,i8,i16,i32,i64)bitis 0-based (0 = LSB) and must be within the source type’s bit width- The bit-mapped variable must be type
bool sourceandbitmust 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
| Field | Type | Required | Description |
|---|---|---|---|
type | string | yes | Data type (see tables above) |
link | string | no | Hardware FQDN link (e.g., "ethercat.el2004.output1") |
description | string | no | Human-readable description |
initial | any | no | Initial value (as JSON, parsed based on type) |
nonvolatile | bool | no | When true, value persists across restarts |
max_length | number | no | String capacity in bytes (default: 64, max: 255) |
source | string | no | Source variable for bit-mapped bools |
bit | number | no | Bit 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)
}
Blink — Simple Oscillator
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:
| Field | Type | Description |
|---|---|---|
q | bool | Output — true when timer has elapsed |
et | Duration | Elapsed time since input became true |
BitResetOnDelay — Auto-Reset Timer
Sets output to false after a delay. Useful for pulse outputs.
#![allow(unused)]
fn main() {
use autocore_std::fb::BitResetOnDelay;
use std::time::Duration;
let mut reset = BitResetOnDelay::new(Duration::from_millis(500));
// When you set the bit to true, it automatically resets to false after 500ms
reset.set(); // Output becomes true
// ... 500ms later, in process_tick ...
reset.call(); // Call every cycle to update
// reset.q becomes false after the delay
}
RunningAverage — Online Averaging
Computes a running average of values.
#![allow(unused)]
fn main() {
use autocore_std::fb::RunningAverage;
let mut avg = RunningAverage::new();
avg.add(10.0);
avg.add(20.0);
avg.add(30.0);
let mean = avg.average(); // 20.0
let count = avg.count(); // 3
avg.reset(); // Start over
}
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:
| Method | Signature | Description |
|---|---|---|
new | () -> Self | Create 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) -> bool | A shutdown initiation is pending |
is_cancelling | (&self) -> bool | A shutdown cancellation is pending |
Output fields:
| Field | Type | Description |
|---|---|---|
busy | bool | true while waiting for the server to respond |
done | bool | true for one cycle after the server confirms the command |
error | bool | true for one cycle after the server returns an error |
error_message | String | Error 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:
| Method | Signature | Description |
|---|---|---|
new | (daq_fqdn: &str) -> Self | Create 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:
| Field | Type | Description |
|---|---|---|
busy | bool | true from the rising edge of execute until capture completes or an error/timeout occurs |
active | bool | true while the DAQ is armed and waiting for the hardware trigger event |
error | bool | true when an error or timeout occurs. Stays true until the next rising edge of execute. |
error_message | String | Error description (empty when no error) |
data | Option<CaptureData> | Some(...) after a successful capture, None otherwise |
CaptureData fields:
| Field | Type | Description |
|---|---|---|
channels | Vec<Vec<f64>> | Sample data per channel. channels[ch_idx][sample_idx]. |
channel_count | usize | Number of channels in the capture |
capture_length | usize | Configured post-trigger samples per channel |
pre_trigger_samples | usize | Configured pre-trigger samples per channel |
actual_samples | usize | Total samples per channel actually written (pre + post) |
sample_rate | f64 | Sample rate in Hz |
timestamp_ns | u64 | UNIX timestamp (nanoseconds) of the trigger event |
sequence | u64 | Monotonically 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:
- Peak tracking — maintains a running largest-magnitude
peak_loadthat resets on tare orreset_peak(). - Tare — pulses the terminal’s tare output bit high for 100 ms and zeros the peak.
- 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
| Method | Signature | Description |
|---|---|---|
new | (device: &str) -> Self | Create 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
| Method | Signature | Non-blocking | Description |
|---|---|---|---|
tick | (&mut self, view: &mut El3356View, client: &mut CommandClient) | yes | Call every scan. Updates peak_load from view.load, releases the 100 ms tare pulse, and progresses any active SDO sequence. |
tare | (&mut self) | yes | Start 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) | yes | Zero peak_load. No IPC. |
reset | (&mut self) | yes | Full 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) | yes | Clear error and error_message. |
Calibration (three-step SDO sequences on 0x8000)
| Method | Signature | Non-blocking | Description |
|---|---|---|---|
configure | (&mut self, client: &mut CommandClient, full_scale_load: f32, sensitivity_mv_v: f32, scale_factor: f32) | yes | Start 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) | yes | Start 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.
| Method | Signature | Writes sub | Description |
|---|---|---|---|
set_mode0_filter_enabled | (&mut self, client, enable: bool) | 0x01 | Enable/disable the software filter in Mode 0 (10.5 kSps, high-precision). Default TRUE. |
set_mode1_filter_enabled | (&mut self, client, enable: bool) | 0x02 | Enable/disable the software filter in Mode 1 (105.5 kSps, fast). Default TRUE. |
set_mode0_averager_enabled | (&mut self, client, enable: bool) | 0x03 | Enable/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) | 0x05 | Enable/disable the 4-sample hardware averager in Mode 1 (~0.014 ms added latency). Default TRUE. |
set_mode0_filter | (&mut self, client, filter: El3356Filters) | 0x11 | Select the Mode 0 software filter. See El3356Filters. |
set_mode1_filter | (&mut self, client, filter: El3356Filters) | 0x12 | Select the Mode 1 software filter. |
Low-level / accessors
| Method | Signature | Non-blocking | Description |
|---|---|---|---|
sdo_write | (&mut self, client, index: u16, sub_index: u8, value: serde_json::Value) | yes | Write 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) | yes | Read 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) -> bool | — | Same as reading the busy field directly. |
is_error | (&self) -> bool | — | Same as reading the error field directly. |
result | (&self) -> serde_json::Value | — | Full 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
| Field | Type | Description |
|---|---|---|
peak_load | f32 | Largest absolute load seen since the last tare or reset_peak(). The signed value at the peak is stored. |
busy | bool | true during a configure() or read_configuration() sequence. |
error | bool | Sticky — set on any SDO failure. Cleared by clear_error() or the start of the next configure() / read_configuration() call. |
error_message | String | Description of the most recent error (empty when none). |
configured_mv_v | Option<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_load | Option<f32> | Current full-scale load (sub 0x24). Same lifecycle as configured_mv_v. |
configured_scale_factor | Option<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
| Field | Type | Direction | Description |
|---|---|---|---|
tare | &mut bool | output | Tare command bit. Written by tick(). |
load | &f32 | input | Scaled load value from the terminal. |
load_steady | &bool | input | Steady-state indicator. true when the signal has been stable within the configured band. |
load_error | &bool | input | General error flag. |
load_overrange | &bool | input | Signal 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.
| Variant | Register value | Cutoff | ~Step-response latency | Typical use |
|---|---|---|---|---|
FIR50Hz | 0 | 50 Hz notch | ~13 ms | Suppress 50 Hz mains hum |
FIR60Hz | 1 | 60 Hz notch | ~16 ms | Suppress 60 Hz mains hum |
IIR1 | 2 | ~2000 Hz | ~0.3 ms | Very fast tracking, minimal smoothing |
IIR2 | 3 | ~500 Hz | ~0.8 ms | Light smoothing |
IIR3 | 4 | ~125 Hz | ~3.5 ms | Fast machinery tracking |
IIR4 | 5 | ~30 Hz | ~14 ms | Moderate mechanical vibration rejection |
IIR5 | 6 | ~8 Hz | ~56 ms | Slower processes |
IIR6 | 7 | ~2 Hz | ~225 ms | Heavy smoothing, mostly static loads |
IIR7 | 8 | ~0.5 Hz | ~900 ms | Very heavy smoothing |
IIR8 | 9 | ~0.1 Hz | ~3600 ms | Maximum damping, fully static measurement |
DynamicIIR | 10 | variable | ~0.3 ms – ~3600 ms | Auto-switches between IIR1 and IIR8 based on signal change rate — good for dosing/filling (fast track + static precision) |
PDOFilterFrequency | 11 | variable | depends | FIR 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:
| Mode | ADC rate | Hardware latency | Typical filter pairing | Typical use |
|---|---|---|---|---|
| 0 — High Precision (default) | 10.5 kSps | ~7.2 ms | Strong IIR (IIR5–IIR8) | Static weighing, high-accuracy calm readings |
| 1 — High Speed | 105.5 kSps | ~0.72 ms | Weak 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 concept | Rust equivalent |
|---|---|
AT %I* inputs (fLoad, bLoadSteady, bLoadError, bLoadOverrange) | &T fields on El3356View |
AT %Q* output (bTare) | &mut bool on El3356View |
nCommandCode / nStatusCode handshake | Method calls (.tare(), .configure()) + pub busy: bool field |
rtTare / ftTare edge triggers on a manual-tare button | RTrig 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 timing | Internal to the FB — tick() clears the tare bit automatically 100 ms after tare() is called. |
stEL3356.fPeakLoad := fLoad peak update | Automatic 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:
| Method | Signature | Description |
|---|---|---|
new | () -> Self | Create 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) -> bool | true while seek operation is in progress |
is_error | (&self) -> bool | true if an error occurred during the seek |
error_code | (&self) -> i32 | Returns the error code from the state machine |
Output fields:
| Field | Type | Description |
|---|---|---|
done | bool | true for one cycle when the probe is found and axis has stopped |
error | bool | true when an error occurs |
state | StateMachine | Internal state machine with index, error_code, error_message |
Error codes:
| Code | Meaning |
|---|---|
| 1 | Abort/Reset called while motion was active |
| 100 | Axis position is not > 0 at start |
| 120 | Axis error or control disabled during motion |
| 200 | Axis 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:
| Field | Type | Default | Description |
|---|---|---|---|
kp | f64 | 0.0 | Proportional gain. |
ki | f64 | 0.0 | Integral gain. |
kd | f64 | 0.0 | Derivative gain. |
feed_forward | f64 | 0.0 | Feed forward value added directly to the output. |
max_step | f64 | 0.005 | Maximum allowed position delta (in user units) per call/tick. Critical safety limit to prevent crushing. |
max_integral | f64 | 100.0 | Maximum accumulated integral windup. |
filter_alpha | f64 | 0.5 | EMA filter coefficient (0.0 to 1.0). 1.0 = No filtering (raw data), 0.1 = Heavy filtering. |
invert_direction | bool | false | Set to true if moving the axis negative increases compression (e.g., a downward Z-axis). |
tolerance | f64 | 1.0 | Acceptable load error window to be considered “in tolerance” (e.g., +/- 2.0 lbs). |
settling_time | f64 | 0.1 | How 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:
| Field | Type | Description |
|---|---|---|
active | bool | true when the block is actively executing and controlling the axis. |
in_tolerance | bool | true when the current load has been within config.tolerance for at least config.settling_time seconds. |
error | bool | true if a fault occurred (e.g., axis error). Check state.error_code. |
state | StateMachine | Internal 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:
| Field | Type | Description |
|---|---|---|
done | bool | true when the target load edge has been reached and the axis has halted. |
active | bool | true when the block is actively executing motion. |
error | bool | true if a fault occurred (e.g., reached position limit, axis error). Check state.error_code. |
state | StateMachine | Internal state machine for operation sequencing and error reporting. |
Error codes:
| Code | Meaning |
|---|---|
| 1 | Abort called |
| 110 | Axis already past position limit before starting |
| 120 | Axis is in an error state |
| 150 | Reached 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);
}
}
Banner
Import Banner device helpers from autocore_std::banner::wls15.
Wls15RunMode — WLS15P IO-Link Light Strip
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:
| Method | Signature | Description |
|---|---|---|
new | () -> Self | Create 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):
| Field | Type | Description |
|---|---|---|
animation | u8 | Animation mode (see Animation enum) |
color1 | u8 | Primary color (see Color enum) |
color1_intensity | u8 | Primary color intensity (see ColorIntensity enum) |
color2 | u8 | Secondary color |
color2_intensity | u8 | Secondary color intensity |
speed | u8 | Animation speed (see Speed enum) |
pulse_pattern | u8 | Pulse pattern (see PulsePattern enum) |
scroll_bounce_style | u8 | Scroll/bounce style (see ScrollStyle enum) |
percent_width_color1 | u8 | Color 1 width percentage (0-100) |
direction | u8 | Direction: 0=Up, 1=Down |
Enums:
All enums are #[repr(u8)] and map directly to hardware PDO values.
| Enum | Values |
|---|---|
Animation | Off(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) |
Color | Green(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) |
ColorIntensity | High(0), Low(1), Medium(2), Off(3), Custom(4) |
Speed | Medium(0), Fast(1), Slow(2), CustomFlashRate(3) |
PulsePattern | Normal(0), Strobe(1), ThreePulse(2), Sos(3), Random(4) |
ScrollStyle | Solid(0), Tail(1), Ripple(2) |
Direction | Up(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:
| Q1 | Q2 | Color |
|---|---|---|
false | false | Off |
true | false | Red |
false | true | Green |
true | true | Blue |
Methods:
| Method | Signature | Description |
|---|---|---|
new | () -> Self | Create 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:
| Field | Type | Description |
|---|---|---|
q1 | bool | Digital output 1 — connect to light strip channel 1 |
q2 | bool | Digital 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:
| Method | Signature | Description |
|---|---|---|
new | (device: &str) -> Self | Create a client scoped to a device (e.g. "ClearPath_0") |
write | (&mut self, client, index: u16, sub_index: u8, value: Value) -> u32 | Issue SDO write; returns transaction handle |
read | (&mut self, client, index: u16, sub_index: u8) -> u32 | Issue SDO read; returns transaction handle |
result | (&mut self, client, tid: u32, timeout: Duration) -> SdoResult | Check result of in-flight request |
drain_stale | (&mut self, client, timeout: Duration) | Remove requests pending longer than timeout |
pending_count | (&self) -> usize | Number of in-flight SDO requests |
SdoResult variants:
| Variant | Description |
|---|---|
Pending | No 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") |
Timeout | No response within caller-specified deadline |
IPC topics used internally:
| Operation | Topic | Payload |
|---|---|---|
| Write | ethercat.{device}.sdo_write | {"index": "0x6060", "sub": 0, "value": 1} |
| Read | ethercat.{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:
| Method | Description |
|---|---|
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) -> u32 | Start an SDO read (returns transaction ID) |
sdo_result(client, tid) -> SdoResult | Check result of a previous SDO read |
Status methods:
| Method | Returns | Description |
|---|---|---|
position() | f64 | Current position in user units |
raw_position() | i64 | Current position in encoder counts |
speed() | f64 | Current speed in user units/s (absolute) |
is_busy() | bool | Any operation in progress |
is_error() | bool | Drive fault or operation error |
error_code() | u32 | Drive error code |
error_message() | &str | Human-readable error description |
motor_on() | bool | Drive in Operation Enabled state |
in_motion() | bool | Move specifically in progress |
moving_positive() | bool | Velocity is positive |
moving_negative() | bool | Velocity is negative |
at_max_limit() | bool | At positive software limit |
at_min_limit() | bool | At negative software limit |
at_positive_limit_switch() | bool | Positive hardware limit active |
at_negative_limit_switch() | bool | Negative hardware limit active |
home_sensor() | bool | Home sensor active |
Public fields:
| Field | Type | Description |
|---|---|---|
axis | Axis | The underlying axis state machine (for advanced use with SeekProbe, etc.) |
snapshot | Cia402PpSnapshot | The 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. TheAxiswrites SDO 0x6098 (method), 0x6099 (speeds), 0x609A (acceleration), then triggers the drive’s internal homing. - Software methods (
LimitSwitchNegPnp,HomeSensorPosPnp, etc.) are implemented by theAxisitself. 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 inoptionsin 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:
| Field | Type | Default | Description |
|---|---|---|---|
positive_limit | string | — | GlobalMemory bool for positive limit switch |
negative_limit | string | — | GlobalMemory bool for negative limit switch |
home_sensor | string | — | GlobalMemory bool for home reference sensor |
error_code | string | — | GlobalMemory u16 for drive error code |
invert_direction | bool | false | Negate 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:
| Field | GM type | Description |
|---|---|---|
position | f64 | Position in user units |
raw_position | i64 | Position in encoder counts |
speed | f64 | Speed in user units/s |
is_busy | bool | Any operation in progress |
is_error | bool | Fault or error occurred |
error_code | u32/i32 | Drive error code |
error_message | string | Error description |
motor_on | bool | Drive enabled |
in_motion | bool | Move in progress |
moving_positive | bool | Moving in positive direction |
moving_negative | bool | Moving in negative direction |
at_max_limit | bool | At positive software limit |
at_min_limit | bool | At negative software limit |
at_positive_limit_switch | bool | Positive hardware limit active |
at_negative_limit_switch | bool | Negative hardware limit active |
home_sensor | bool | Home 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
6098hvalue 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_ackwhile halt is asserted. The WaitCancelAck stage stalls andAxisreportsHalt 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:
| Variant | Kind | Description |
|---|---|---|
HardStopPos | Integrated | Hard stop positive direction (torque foldback) |
HardStopNeg | Integrated | Hard stop negative direction |
IntegratedLimitSwitchPos | Integrated | Drive’s positive limit switch (CiA 402 code 18) |
IntegratedLimitSwitchNeg | Integrated | Drive’s negative limit switch (CiA 402 code 17) |
IntegratedHomeSensorPosRt | Integrated | Drive’s home sensor, positive, rising edge |
IntegratedHomeSensorNegRt | Integrated | Drive’s home sensor, negative, rising edge |
CurrentPosition | Integrated | Set current position as home (no movement) |
Integrated(i8) | Integrated | Arbitrary CiA 402 code (vendor-specific) |
LimitSwitchPosPnp | Software | Move positive, home on positive limit (PNP: true = detected) |
LimitSwitchNegPnp | Software | Move negative, home on negative limit (PNP: true = detected) |
LimitSwitchPosNpn | Software | Move positive, home on positive limit (NPN: false = detected) |
LimitSwitchNegNpn | Software | Move negative, home on negative limit (NPN: false = detected) |
HomeSensorPosPnp | Software | Move positive, home on home sensor (PNP: true = detected) |
HomeSensorNegPnp | Software | Move negative, home on home sensor (PNP: true = detected) |
HomeSensorPosNpn | Software | Move positive, home on home sensor (NPN: false = detected) |
HomeSensorNegNpn | Software | Move 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:
| Method | Signature | Description |
|---|---|---|
new | (daq_fqdn: &str) -> Self | Creates 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) -> bool | true while arming, waiting, or reading data |
is_error | (&self) -> bool | true 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: usizeactual_samples: usizesample_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:
| Method | Signature | Description |
|---|---|---|
new | () -> Self | Create 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.
| Method | Signature | Description |
|---|---|---|
send | (&mut self, topic: &str, data: Value) -> u32 | Send a request. Returns the transaction_id. |
poll | (&mut self) | Drain all available responses from the WebSocket into the buffer. Called automatically by the framework before each process_tick. |
take_response | (&mut self, transaction_id: u32) -> Option<CommandMessage> | Take a response for a specific transaction_id. Returns None if not yet arrived. |
is_pending | (&self, transaction_id: u32) -> bool | Check if a request is still awaiting a response. |
pending_count | (&self) -> usize | Number of outstanding requests. |
response_count | (&self) -> usize | Number of buffered responses ready to be claimed. |
drain_stale | (&mut self, timeout: Duration) -> Vec<u32> | Remove and return transaction_ids that have been pending longer than timeout. |
CommandMessage response fields:
| Field | Type | Description |
|---|---|---|
transaction_id | u32 | Matches the ID returned by send() |
success | bool | Whether the request was processed successfully |
data | serde_json::Value | The response payload (on success) |
error_message | String | Error description (on failure) |
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:
| Method | Description |
|---|---|
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):
| Method | Description |
|---|---|
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:
| Method | Description |
|---|---|
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:
-
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.
-
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 likefd7a: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.xaddress 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 the192.168.127.xrange. 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>::/96route → 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
hostsfile, 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.
-
The embedded computer is running
autocore-server. If you can reach the AutoCore Console in your web browser, this is already true. -
Tailscale is installed and signed in on the embedded computer. You can verify by SSHing to the embedded computer and running:
tailscale statusThe first line of the output should not say “Logged out.” If it does, run
sudo tailscale upand sign in following the URL it prints. You only do this once. -
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. -
The embedded computer is connected to the process network. Run:
ip -4 addr showYou 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 on192.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
-
In your web browser, go to the AutoCore Console URL for the embedded computer. This will be something like:
http://adc-sn-3833/consoleor, if name resolution isn’t set up, the embedded computer’s Tailscale IPv4 address — find it by running
tailscale ip -4on the embedded computer. -
At the top of the console you will see two tabs: Console and Network Bridge. Click Network Bridge.
-
The page that loads has four sections, top to bottom:
- Network Bridge header — your machine’s identity.
- Local Subnets — the LANs the embedded computer is connected to.
- Devices on <address> — devices the embedded computer can see on a chosen subnet.
- 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, likefd7a:115c:a1e0:b1a:0:ef9::/96. This is the unique IPv6 space reserved for this embedded computer. Theef9part 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 saysNeedsLoginorStopped, 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 = 3833entry to/opt/autocore/config/config.iniand restartautocore_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.
-
Find the row whose
CIDRcolumn begins192.168.127. (The CIDR is the address-and-prefix notation you see in the column; on a typical ADC machine you’ll see192.168.127.1/24.) -
Click the Advertise button at the right of that row.
-
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.
-
In a new browser tab, go to:
https://login.tailscale.com -
Sign in if you aren’t already.
-
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). -
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.
-
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.)
-
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. -
Tick the checkbox to approve the route.
-
Click Save (button label varies by Tailscale version).
-
Switch back to the AutoCore Console browser tab. Click the Refresh button at the top of the Network Bridge panel.
-
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.
-
In the Local Subnets table, click Pick on the process network row to select it as the scan target.
-
Scroll down to Devices on 192.168.127.1 (or whatever your local address is).
-
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.
-
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.iniand survive restarts. - Type a label in the Label column (e.g.
-
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.
-
Scroll to the Hosts-file snippet section at the bottom of the page. After the scan, the box should now contain several lines.
-
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-3833The
-3833suffix 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 withpanel-pc-3833,panel-pc-4012,panel-pc-4509all distinct. -
Click Copy block. The full text is now on your clipboard.
-
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):
-
In the Local Subnets table, click Disable on the row whose bridge is currently active.
-
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.
-
If you also want to revoke approval in the admin console for cleanliness:
- Go back to
login.tailscale.com→ Machines → your node → Edit route settings → uncheck the route → Save.
- Go back to
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:
- 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.
- 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. - 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.
- 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.
- 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:
- Install Tailscale and sign in to the company tailnet.
- Paste the text block into your laptop’s
hostsfile.
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:
-
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.
-
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-3833If you don’t have one, ask. Tell them which project you’re working on so they generate it from the right machine.
-
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
- Go to tailscale.com/download/windows and download the installer.
- Run the installer. Click through the defaults.
- 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).
- 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
- Install from the Mac App Store: search for Tailscale.
- Open the Tailscale app. A small icon appears in the menu bar at the top of the screen.
- Click the icon, choose Log in, and sign in.
Linux
-
Follow the official instructions at tailscale.com/download/linux for your distribution. For Ubuntu, that’s:
curl -fsSL https://tailscale.com/install.sh | sh -
Sign in:
sudo tailscale upThis 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
-
Open a terminal.
-
Open the file with
sudo(you will be prompted for your laptop password):sudo nano /etc/hostsYou can use any other text editor if you prefer —
sudo vim /etc/hosts, etc. — butnanois the simplest if you don’t have a preference. -
Use the arrow keys to move to the end of the file. Press Enter to start a new line.
-
Paste the snippet (in
nano, paste is usuallyCtrl+Shift+Von Linux orCmd+Von macOS; in other terminals it may differ). -
Save and exit:
- In
nano:Ctrl+O, Enter, thenCtrl+X. - In
vim:Esc, then:wq, then Enter.
- In
-
Verify by running:
getent hosts panel-pc-3833(Replace
panel-pc-3833with 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
-
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.) -
In Notepad, choose File → Open.
-
In the file path field at the bottom, type:
C:\Windows\System32\drivers\etc\hostsand press Enter. (If Notepad shows “no files found,” change the file-type dropdown from “Text Documents (*.txt)” to All Files.)
-
Move to the end of the file and press Enter to add a new line.
-
Paste the snippet (
Ctrl+V). -
Save with File → Save (
Ctrl+S). -
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.
-
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).
-
Run
tailscale statusin a terminal. The first line should not sayLogged out. -
Verify you can reach the embedded computer itself (not just the bridged devices):
ping adc-sn-3833If 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.
-
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.
-
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 # WindowsNo output / “could not find host” → snippet not in your hosts file. Re-do section 2.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 statuson 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:
- 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. - 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:
- SSH into the embedded computer (using its friendly Tailscale name,
e.g.
ssh user@adc-sn-3833). - Tell SSH to forward a port on your laptop to the target device’s IPv4 + port on the embedded computer’s side.
- 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 statusshows 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 statusfrom 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.