macchina.io EDGE

Modbus Programming Guide

Introduction

macchina.io comes with support for the Modbus protocol. Both the TCP and RTU (serial port/RS-485) variants of Modbus are supported. In a Modbus network, macchina.io EDGE is usually the master or client, but there's also a Modbus server available based on datapoints.

For implementing a Modbus client, macchina.io EDGE provides the IoT::Modbus::ModbusMaster service interface, which provides support for sending and receiving all defined Modbus messages. The interface can be used in two modes, synchronous mode, and asynchronous mode.

Multiple master instances can be set up simultaneously. For example, there may be a ModbusMaster service that uses an RS-485 connection to talk to various industrial devices, and there may simultaneously be multiple other ModbusMaster services, each talking to a different Modbus slave device over a TCP connection.

Modbus Client Usage

Configuring a Modbus RTU Master

In order to configure a Modbus RTU master, an RS-485 interface must be available. The RS-485 interface is accessed using the standard Linux (or POSIX) serial port device interface. To set-up a ModbusMaster service instance for a specific serial port, add the following settings to the macchina.properties configuration file.

The following sample configuration is for a RS-485 to USB adapter (FTDI) connected to a Linux device, assuming that the device path of the adapter is /dev/ttyUSB0.

#
# Modbus RTU
#
modbus.rtu.ports.adam.device = /dev/ttyUSB0
modbus.rtu.ports.adam.speed = 19200

Note that the adam part of the parameter name can later be used to find the corresponding ModbusMaster instance in the service registry. In this case we'll connect an Advantech ADAM 4068 Relay module, thus we chose the name adam. Any other name (alphanumeric, starts with letter, may include dashes -) is fine as well.

The main things to configure are the serial device (modbus.rtu.ports.<id>.device) and the port speed (modbus.rtu.ports.adam.speed). You can also specify communication parameters (modbus.rtu.ports.<id>.params, defaults to "8N1") and timeouts for commands (modbus.rtu.ports.<id>.timeout, given in milliseconds, defaults to 2000) and frames (modbus.rtu.ports.<id>.frameTimeout, given in microseconds, defaults to 10000). There are a few more options for special cases, like the RS-485 support on a BeagleBone, which requires the configuration of an additional GPIO pin. See the reference section at the end of this article for more information.

After updating the configuration file and restarting macchina.io, a new ModbusMaster service instance will be available in the service registry.

The name used in the modbus.rtu.ports configuration settings is reflected in the service name. For the above example (adam), the service name would be io.macchina.modbus.rtu#adam.

Using the ModbusMaster Service

Following is some JavaScript code that can be run in the Playground to test the Modbus interface. The script assumes that an ADAM 4068 Relay device is connected to the RS-485 bus. The Modbus slave address of the ADAM 4068 should be set to 1. The script will turn on and off the relays of the ADAM 4068 one after another, generating a "running light" effect on the LEDs showing the relay states. It uses the Modbus Write Single Coil function (Modbus function code 0x05) to set the relay states.

let modbus = null;
const modbusRef = serviceRegistry.findByName('io.macchina.modbus.rtu#adam');
if (modbusRef)
{
   modbus = modbusRef.instance();
   logger.notice("Modbus service found");
}

let currentCoil = 0;
const nOfCoils = 8;
let slaveId = 1;

setInterval(() => {

    let lastCoil = currentCoil - 1;
    if (lastCoil < 0) lastCoil = nOfCoils - 1;

    modbus.writeSingleCoil(slaveId, 16 + lastCoil, false);
    modbus.writeSingleCoil(slaveId, 16 + currentCoil, true);

    currentCoil++;
    if (currentCoil >= nOfCoils) currentCoil = 0;


}, 500);

Configuring a Modbus TCP Master

To create a ModbusMaster service for a Modbus TCP device, the following configuration properties can be used:

#
# Modbus TCP
#
modbus.tcp.ports.adam.hostAddress = 192.168.1.123
modbus.tcp.ports.adam.portNumber = 502

The corresponding service name would then consequently be io.macchina.modbus.tcp#adam. Specifying the port number is optional if the default port 502 is used. Additionally, the command timeout (modbus.tcp.ports.<id>.timeout, default 2000) can be specified in milliseconds, as with the Modbus RTU configuration.

To use the Modbus TCP master in the JavaScript sample shown above, change the second line to:

const modbusRef = serviceRegistry.findByName('io.macchina.modbus.tcp#adam');

Using the Asynchronous Modbus API

In addition to the synchronous API, offering blocking calls like readHoldingRegisters() or writeSingleCoil(), the ModbusMaster interface also provides non-blocking methods. These will add the request to an internal queue, from which they will be sent to the device. Responses and any errors that may occur are then reported via events. For example, the response to a sendReadHoldingRegistersRequest() will be reported via the readHoldingRegistersResponseReceived event. Requests and responses can be correlated via the transaction ID that is returned by the sendReadHoldingRegistersRequest() method (and other send...Request()) methods. Transaction IDs are normally used for Modbus TCP only, but the implementation provides "synthetic" transaction IDs for Modbus RTU.

Queued requests that cannot be sent before they timeout generate a timeout error, reported via the timeout and requestFailed events.

Switching Between Synchronous and Asynchronous APIs

Calling one of the non-blocking methods (send...Request()) will put the client into asynchronous mode. This includes managing a queue of requests. The queue takes care of not sending more than the maximum number of concurrent requests possible. For Modbus RTU, generally only one requests can be sent at a time. For Modbus TCP, the number of maximum simultaneous requests is configurable and defaults to 16 (as specified in the Modbus TCP specification).

Calling one of the blocking methods (e.g. readHoldingRegisters()) will end asynchronous mode and cancel any outstanding asynchronous requests.

Error Handling

A Modbus exception message received in response to a request will be reported via the exceptionReceived event. Any failure (including timeout) will be reported via requestFailed. For timeouts, there's also a separate timeout event, providing the transaction ID as parameter. However, it is recommended to handle all errors, including timeouts, via the requestFailed event to simplify the implementation.

Following is an example for using the asynchronous API to read a holding register.

let modbus = null;
const modbusRef = serviceRegistry.findByName('io.macchina.modbus.tcp#sensor');
if (modbusRef)
{
   modbus = modbusRef.instance();
   logger.notice("Modbus service found");
}

modbus.on('readHoldingRegistersResponseReceived', ev => {
    logger.debug('readHoldingRegistersResponseReceived: %O', ev.data);
    let temp = ev.data.registerValues[0]/100.0;
    logger.information('Temperature: %f', temp);
});

modbus.on('requestFailed', ev => {
    logger.error('Modbus request failed: %O', ev.data);
});

let xid = modbus.sendReadHoldingRegistersRequest({
    startingAddress: 2000,
    nOfRegisters: 1
});
console.debug('Transaction ID: %d', xid);

Using the ModbusMaster Interface from C++

Please see the ModbusRunningCoils sample for an implementation of the "running coils" JavaScript sample in C++.

The sample is implemented as a bundle that first finds a IoT::Modbus::IModbusMaster service instance, then starts a timer to control the coils.

The complete BundleActivator code, which also contains the timer task, is shown in the following.

#include "Poco/OSP/BundleActivator.h"
#include "Poco/OSP/BundleContext.h"
#include "Poco/OSP/ServiceRegistry.h"
#include "Poco/OSP/ServiceFinder.h"
#include "Poco/OSP/ServiceRef.h"
#include "Poco/OSP/PreferencesService.h"
#include "Poco/Util/Timer.h"
#include "Poco/Util/TimerTask.h"
#include "Poco/Format.h"
#include "Poco/ClassLibrary.h"
#include "IoT/Modbus/IModbusMaster.h"


namespace ModbusRunningCoils {


class RunningCoilsTask: public Poco::Util::TimerTask
{
public:
	RunningCoilsTask(IoT::Modbus::IModbusMaster::Ptr pModbusMaster, Poco::UInt8 slaveId, int nOfCoils, int baseAddress):
		_pModbusMaster(pModbusMaster),
		_slaveId(slaveId),
		_nOfCoils(nOfCoils),
		_baseAddress(baseAddress)
	{
	}

	void run()
	{
		int lastCoil = _currentCoil - 1;
		if (lastCoil < 0) lastCoil = _nOfCoils - 1;

		_pModbusMaster->writeSingleCoil(_slaveId, static_cast<Poco::UInt16>(_baseAddress + lastCoil), false);
		_pModbusMaster->writeSingleCoil(_slaveId, static_cast<Poco::UInt16>(_baseAddress + _currentCoil), true);

		_currentCoil++;
		if (_currentCoil >= _nOfCoils) _currentCoil = 0;
	}

private:
	IoT::Modbus::IModbusMaster::Ptr _pModbusMaster;
	Poco::UInt8 _slaveId;
	int _nOfCoils;
	int _currentCoil = 0;
	int _baseAddress;
};


class BundleActivator: public Poco::OSP::BundleActivator
{
public:
	void start(Poco::OSP::BundleContext::Ptr pContext)
	{
		_pContext = pContext;
		_pPrefs = Poco::OSP::ServiceFinder::find<Poco::OSP::PreferencesService>(pContext);

		std::string modbusMasterName = _pPrefs->configuration()->getString("modbusRunningCoils.modbusMaster", "io.macchina.modbus.rtu#adam");
		Poco::UInt8 slaveId = static_cast<Poco::UInt8>(_pPrefs->configuration()->getUInt("modbusRunningCoils.modbusSlave", 1));
		int nOfCoils = _pPrefs->configuration()->getInt("modbusRunningCoils.nOfCoils", 8);
		int baseAddress = _pPrefs->configuration()->getInt("modbusRunningCoils.baseAddress", 16);
		long interval = _pPrefs->configuration()->getInt("modbusRunningCoils.interval", 500);

		_pModbusMasterRef = pContext->registry().findByName(modbusMasterName);
		if (_pModbusMasterRef)
		{
			_pModbusMaster = _pModbusMasterRef->castedInstance<IoT::Modbus::IModbusMaster>();
			_timer.scheduleAtFixedRate(new RunningCoilsTask(_pModbusMaster, slaveId, nOfCoils, baseAddress), interval, interval);
		}
		else
		{
			_pContext->logger().warning("No ModbusMaster found.");
		}
	}

	void stop(Poco::OSP::BundleContext::Ptr pContext)
	{
		_timer.cancel(true);
		_pModbusMaster.reset();
		_pModbusMaster.reset();
		_pPrefs.reset();
		_pContext.reset();
	}

private:
	Poco::OSP::BundleContext::Ptr _pContext;
	Poco::OSP::PreferencesService::Ptr _pPrefs;
	Poco::OSP::ServiceRef::Ptr _pModbusMasterRef;
	IoT::Modbus::IModbusMaster::Ptr _pModbusMaster;
	Poco::Util::Timer _timer;
};


} // namespace ModbusRunningCoils


POCO_BEGIN_MANIFEST(Poco::OSP::BundleActivator)
	POCO_EXPORT_CLASS(ModbusRunningCoils::BundleActivator)
POCO_END_MANIFEST

Modbus Configuration Reference

Modbus RTU Configuration Properties

  • modbus.rtu.ports.<port>.enable: Enable (true, default) or disable (false) this Modbus client instance.
  • modbus.rtu.ports.<port>.device: Specify the device for the RS/TIA-485 interface, e.g. /dev/ttyUSB0.
  • modbus.rtu.ports.<port>.params: Specify the serial port parameters, including number of data bits, parity and number of stop bits, e.g. 8N1 (default). See Poco::Serial::SerialPort::open() for more information on this parameter.
  • modbus.rtu.ports.<port>.speed: Specify the baud rate, e.t. 9600 (default), 19200, 57600, etc.
  • modbus.rtu.ports.<port>.timeout: Specify the request timeout in milliseconds. Defaults to 2000 ms.
  • modbus.rtu.ports.<port>.frameTimeout: Specify the timeout for receiving a single protocol frames. Given in Microseconds and defaults to 100000 (10 ms).
  • modbus.rtu.ports.<port>.frameSpacing: Specify a minimum interval between message frames sent. Only used by the asynchronous API. Given in microseconds, defaults to 3000 us.
  • modbus.rtu.ports.<port>.maxAsyncQueueSize: Specify the maximum number of queued requests.

RS/TIA-485 Configuration

  • modbus.rtu.ports.<port>.rs485.enable: Enable (true) or disable (false, default) RS-485 mode on the serial interface.
  • modbus.rtu.ports.<port>.rs485.rtsOnSend: Set (true) or don't set (false, default) the RTS signal to 1 when sending data, thereby enabling the RS-485 driver.
  • modbus.rtu.ports.<port>.rs485.rtsAfterSend: Set (true) or don't set (false, default) the RTS signal after sending data, thereby disabling the RS-485 driver.
  • modbus.rtu.ports.<port>.rs485.useGPIO: Use (true) a GPIO pin to enable the RS-485 driver rather than RTS. Used by some devices like Beaglebones. Defaults to false.
  • modbus.rtu.ports.<port>.rs485.gpioPin: If useGPIO is set, specify the number of the GPIO pin to use to enable the RS-485 driver.
  • modbus.rtu.ports.<port>.rs485.delayRTSBeforeSend: Specify the RTS delay in microseconds when sending data. Defaults to 0.
  • modbus.rtu.ports.<port>.rs485.delayRTSAfterSend: Specify the RTS delay in microseconds after sending data. Defaults to 0.

Modbus TCP Configuration Properties

  • modbus.tcp.ports.<port>.enable: Enable (true, default) or disable (false) this Modbus client instance.
  • modbus.tcp.ports.<port>.hostAddress: Specify the IP address or domain name of the Modbus server device.
  • modbus.tcp.ports.<port>.portNumber: Specify the TCP port number of the Modbus server device. Defaults to 502.
  • modbus.tcp.ports.<port>.timeout: Specify the request timeout in milliseconds. Defaults to 2000 ms.
  • modbus.tcp.ports.<port>.connectTimeout: Specify the TCP connect timeout. Defaults to 2000 ms.
  • modbus.tcp.ports.<port>.lazyConnect: If set to true, don't connect to the device until the first request needs to be sent. If set to false (default), try to connect immediately to the device. This may delay startup if the device is not available.
  • modbus.tcp.ports.<port>.maxSimultaneousTransactions: Specify the maximum number of requests that can be "in flight" at a given time. Modbus TCP usually allows up to 16 outstanding requests, but some devices may not support receiving additional requests while still handling a previous request. Defaults to 16.
  • modbus.tcp.ports.<port>.maxAsyncQueueSize: Specify the maximum number of queued requests.
Securely control IoT edge devices from anywhere   Connect a Device