Hardware Integration: Modbus TCP
Modbus Overview
Modbus TCP is one of the most common industrial communication protocols. If you have used Modbus with TwinCAT or any other PLC, the concepts are the same — holding registers, input registers, coils, and discrete inputs.
In AutoCore, Modbus communication is handled by the autocore-modbus module. This module:
- Runs as a separate process
- Connects to your Modbus TCP devices
- Cyclically reads and writes registers
- Maps register data into shared memory
Your control program reads and writes Modbus data through variables, just like any other I/O.
Configuring a Modbus Device
Add the Modbus module to your project.json:
{
"name": "modbus_example",
"version": "0.1.0",
"description": "Modbus TCP example",
"control": {
"enable": true,
"source_directory": "./control",
"entry_point": "main.rs",
"signals": {
"tick": {
"source": "internal",
"scan_rate_us": 10000
}
}
},
"modules": {
"modbus": {
"enabled": true,
"args": ["service"],
"config": {
"devices": [
{
"name": "sensor_unit",
"type": "modbus_tcp",
"host": "192.168.1.100",
"port": 502,
"slave_id": 1,
"scan_rate_ms": 100,
"registers": [
{
"name": "temperature",
"type": "input_register",
"address": 0,
"count": 1
},
{
"name": "humidity",
"type": "input_register",
"address": 1,
"count": 1
},
{
"name": "setpoint",
"type": "holding_register",
"address": 0,
"count": 1
}
]
}
]
}
}
},
"variables": {
"temperature_raw": {
"type": "u16",
"direction": "input",
"link": "modbus.sensor_unit.temperature",
"description": "Raw temperature reading (0.1°C per count)"
},
"humidity_raw": {
"type": "u16",
"direction": "input",
"link": "modbus.sensor_unit.humidity",
"description": "Raw humidity reading (0.1% per count)"
},
"setpoint_raw": {
"type": "u16",
"direction": "command",
"link": "modbus.sensor_unit.setpoint",
"description": "Temperature setpoint (0.1°C per count)"
}
}
}
Key configuration fields:
| Field | Description |
|---|---|
modules.modbus.enabled | Set to true to enable the Modbus module |
modules.modbus.args | Must include "service" for the Modbus module |
config.devices[].name | A friendly name for the device (used in variable links) |
config.devices[].host | IP address of the Modbus TCP device |
config.devices[].port | TCP port (usually 502) |
config.devices[].slave_id | Modbus unit ID (1-247) |
config.devices[].registers[].type | "holding_register", "input_register", "coil", or "discrete_input" |
config.devices[].registers[].address | The register address (0-based) |
Linking Variables to Modbus Registers
The "link" field in a variable definition connects it to a Modbus register. The format is:
modbus.<device_name>.<register_name>
For example, if your device is named "sensor_unit" and the register is named "temperature", the link is "modbus.sensor_unit.temperature".
The direction of the variable determines the data flow:
| Variable Direction | Modbus Behavior |
|---|---|
"input" | Module reads from the device and writes to shared memory. Your control program reads it. |
"command" or "output" | Your control program writes to shared memory. Module reads it and writes to the device. |
Example: Reading a Temperature Sensor
This example reads a temperature and humidity sensor via Modbus TCP and converts the raw values to engineering units.
project.json variables (using the Modbus config above):
"variables": {
"temperature_raw": {
"type": "u16",
"direction": "input",
"link": "modbus.sensor_unit.temperature",
"description": "Raw temperature (0.1°C per count)"
},
"humidity_raw": {
"type": "u16",
"direction": "input",
"link": "modbus.sensor_unit.humidity",
"description": "Raw humidity (0.1% per count)"
},
"temperature_degc": {
"type": "f32",
"direction": "status",
"description": "Temperature in °C"
},
"humidity_pct": {
"type": "f32",
"direction": "status",
"description": "Humidity in %"
},
"temp_alarm": {
"type": "bool",
"direction": "status",
"description": "Temperature over limit"
}
}
control/src/program.rs:
#![allow(unused)]
fn main() {
use autocore_std::{ControlProgram, TickContext};
use autocore_std::fb::Ton;
use std::time::Duration;
use crate::gm::GlobalMemory;
pub struct MyControlProgram {
alarm_delay: Ton,
}
impl MyControlProgram {
pub fn new() -> Self {
Self {
alarm_delay: Ton::new(),
}
}
}
impl ControlProgram for MyControlProgram {
type Memory = GlobalMemory;
fn initialize(&mut self, _mem: &mut Self::Memory) {
log::info!("Temperature monitor started");
}
fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
// Convert raw Modbus values to engineering units
// The sensor sends temperature as integer tenths of a degree
ctx.gm.temperature_degc = ctx.gm.temperature_raw as f32 / 10.0;
ctx.gm.humidity_pct = ctx.gm.humidity_raw as f32 / 10.0;
// Alarm if temperature exceeds 50°C for more than 5 seconds
let over_limit = ctx.gm.temperature_degc > 50.0;
ctx.gm.temp_alarm = self.alarm_delay.call(over_limit, Duration::from_secs(5));
// Log every 10 seconds
if ctx.cycle % 1000 == 0 {
log::info!(
"Temp: {:.1}°C, Humidity: {:.1}%, Alarm: {}",
ctx.gm.temperature_degc,
ctx.gm.humidity_pct,
ctx.gm.temp_alarm
);
}
}
}
}
Example: Controlling a VFD (Variable Frequency Drive)
This example shows how to control a VFD (motor drive) over Modbus TCP. Most VFDs use holding registers for speed setpoint and run/stop commands.
project.json (modules section):
"modules": {
"modbus": {
"enabled": true,
"args": ["service"],
"config": {
"devices": [
{
"name": "vfd_01",
"type": "modbus_tcp",
"host": "192.168.1.50",
"port": 502,
"slave_id": 1,
"registers": [
{ "name": "control_word", "type": "holding_register", "address": 0 },
{ "name": "speed_setpoint", "type": "holding_register", "address": 1 },
{ "name": "status_word", "type": "input_register", "address": 0 },
{ "name": "speed_feedback", "type": "input_register", "address": 1 },
{ "name": "current", "type": "input_register", "address": 2 }
]
}
]
}
}
}
Variables:
"variables": {
"vfd_control": { "type": "u16", "direction": "command", "link": "modbus.vfd_01.control_word" },
"vfd_speed_cmd": { "type": "u16", "direction": "command", "link": "modbus.vfd_01.speed_setpoint" },
"vfd_status": { "type": "u16", "direction": "input", "link": "modbus.vfd_01.status_word" },
"vfd_speed_fb": { "type": "u16", "direction": "input", "link": "modbus.vfd_01.speed_feedback" },
"vfd_current": { "type": "u16", "direction": "input", "link": "modbus.vfd_01.current" },
"motor_run_cmd": { "type": "bool", "direction": "command", "description": "Run motor from HMI" },
"motor_speed_rpm": { "type": "f32", "direction": "command", "description": "Speed setpoint in RPM" },
"motor_running": { "type": "bool", "direction": "status", "description": "Motor is running" },
"motor_rpm": { "type": "f32", "direction": "status", "description": "Actual speed in RPM" },
"motor_amps": { "type": "f32", "direction": "status", "description": "Motor current in A" }
}
control/src/program.rs:
#![allow(unused)]
fn main() {
use autocore_std::{ControlProgram, TickContext};
use autocore_std::fb::RTrig;
use crate::gm::GlobalMemory;
pub struct MyControlProgram {
run_trig: RTrig,
stop_trig: RTrig,
}
impl MyControlProgram {
pub fn new() -> Self {
Self {
run_trig: RTrig::new(),
stop_trig: RTrig::new(),
}
}
}
impl ControlProgram for MyControlProgram {
type Memory = GlobalMemory;
fn initialize(&mut self, _mem: &mut Self::Memory) {
log::info!("VFD control started");
}
fn process_tick(&mut self, ctx: &mut TickContext<Self::Memory>) {
// VFD control word bits (typical for many VFDs):
// Bit 0: Run forward
// Bit 1: Run reverse
// Bit 2: Fault reset
const RUN_FWD: u16 = 0x0001;
// Convert HMI speed (RPM) to VFD setpoint
// Many VFDs use 0-10000 = 0-100.00 Hz. Adjust for your drive.
let max_rpm = 1800.0_f32;
let max_freq_counts = 5000_u16; // 50.00 Hz = 1800 RPM for a 4-pole motor
if ctx.gm.motor_run_cmd {
ctx.gm.vfd_control = RUN_FWD;
let speed_pct = (ctx.gm.motor_speed_rpm / max_rpm).clamp(0.0, 1.0);
ctx.gm.vfd_speed_cmd = (speed_pct * max_freq_counts as f32) as u16;
} else {
ctx.gm.vfd_control = 0;
ctx.gm.vfd_speed_cmd = 0;
}
// Convert feedback to engineering units
ctx.gm.motor_running = (ctx.gm.vfd_status & 0x0001) != 0;
ctx.gm.motor_rpm = (ctx.gm.vfd_speed_fb as f32 / max_freq_counts as f32) * max_rpm;
ctx.gm.motor_amps = ctx.gm.vfd_current as f32 / 100.0; // 0.01A resolution
}
}
}