Skip to content

Commit

Permalink
Merge pull request #10 from aldas/rtu_support
Browse files Browse the repository at this point in the history
add support to Modbus RTU
  • Loading branch information
aldas authored Jul 28, 2018
2 parents dfa1aea + b504456 commit f3d4481
Show file tree
Hide file tree
Showing 13 changed files with 274 additions and 20 deletions.
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Modbus TCP protocol client
# Modbus TCP and RTU over TCP protocol client
[![Build Status](https://travis-ci.org/aldas/modbus-tcp-client.svg?branch=master)](https://travis-ci.org/aldas/modbus-tcp-client)
[![codecov](https://codecov.io/gh/aldas/modbus-tcp-client/branch/master/graph/badge.svg)](https://codecov.io/gh/aldas/modbus-tcp-client)

Expand Down Expand Up @@ -46,7 +46,7 @@ Library supports following byte and word orders:

See [Endian.php](src/Utils/Endian.php) for additional info and [Types.php](src/Utils/Types.php) for supported data types.

## Example (fc3 - read holding registers)
## Example of Modbus TCP (fc3 - read holding registers)

Some of the Modbus function examples are in [examples/](examples) folder

Expand Down Expand Up @@ -125,6 +125,21 @@ try {
}
```

## Example of Modbus RTU over TCP
Difference between Modbus RTU and Modbus TCP is that:

1. RTU header contains only slave id. TCP/IP header contains of transaction id, protocol id, length, unitid
2. RTU packed has 2 byte CRC appended

See http://www.simplymodbus.ca/TCP.htm for more detailsed explanation

This library was/is originally meant for Modbus TCP but it has support to convert packet to RTU and from RTU. See this [examples/rtu.php](examples/rtu.php) for example.
```php
$rtuBinaryPacket = RtuConverter::toRtu(new ReadHoldingRegistersRequest($startAddress, $quantity, $slaveId));
$binaryData = $connection->connect()->sendAndReceive($rtuBinaryPacket);
$responseAsTcpPacket = RtuConverter::fromRtu($binaryData);
```

## Example of non-blocking socket IO (i.e. modbus request are run in 'parallel')

Example of non-blocking socket IO with https://github.com/amphp/socket
Expand Down
37 changes: 37 additions & 0 deletions examples/rtu.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

use ModbusTcpClient\Network\BinaryStreamConnection;
use ModbusTcpClient\Packet\ModbusFunction\ReadHoldingRegistersRequest;
use ModbusTcpClient\Packet\RtuConverter;

require __DIR__ . '/../vendor/autoload.php';

$connection = BinaryStreamConnection::getBuilder()
->setPort(502)
->setHost('127.0.0.1')
->setReadTimeoutSec(3) // increase read timeout to 3 seconds
->build();

$startAddress = 256;
$quantity = 6;
$slaveId = 1; // RTU packet slave id equivalent is Modbus TCP unitId

$tcpPacket = new ReadHoldingRegistersRequest($startAddress, $quantity, $slaveId);
$rtuPacket = RtuConverter::toRtu($tcpPacket);

try {
$binaryData = $connection->connect()->sendAndReceive($rtuPacket);
echo 'RTU Binary received (in hex): ' . unpack('H*', $binaryData)[1] . PHP_EOL;

$response = RtuConverter::fromRtu($binaryData);
echo 'Parsed packet (in hex): ' . $response->toHex() . PHP_EOL;
echo 'Data parsed from packet (bytes):' . PHP_EOL;
print_r($response->getData());

} catch (Exception $exception) {
echo 'An exception occurred' . PHP_EOL;
echo $exception->getMessage() . PHP_EOL;
echo $exception->getTraceAsString() . PHP_EOL;
} finally {
$connection->close();
}
7 changes: 7 additions & 0 deletions src/Packet/ByteCountResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace ModbusTcpClient\Packet;


use ModbusTcpClient\Exception\ParseException;
use ModbusTcpClient\Utils\Types;

abstract class ByteCountResponse extends ProtocolDataUnit implements ModbusResponse
Expand All @@ -16,6 +17,12 @@ abstract class ByteCountResponse extends ProtocolDataUnit implements ModbusRespo
public function __construct(string $rawData, int $unitId = 0, int $transactionId = null)
{
$this->byteCount = Types::parseByte($rawData[0]);

$bytesInPacket = (strlen($rawData) - 1);
if ($this->byteCount !== $bytesInPacket) {
throw new ParseException("packet byte count does not match bytes in packet! count: {$this->byteCount}, actual: {$bytesInPacket}");
}

parent::__construct($unitId, $transactionId);
}

Expand Down
2 changes: 1 addition & 1 deletion src/Packet/ModbusFunction/WriteMultipleCoilsResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
class WriteMultipleCoilsResponse extends StartAddressResponse
{
/**
* @var int
* @var int coils written
*/
private $coilCount;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
class WriteMultipleRegistersResponse extends StartAddressResponse
{
/**
* @var int
* @var int number of registers written
*/
private $registersCount;

Expand Down
91 changes: 91 additions & 0 deletions src/Packet/RtuConverter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

namespace ModbusTcpClient\Packet;


use ModbusTcpClient\Exception\ParseException;
use ModbusTcpClient\Utils\Types;

/**
* Converts Modbus TCP/IP packet to/from Modbus RTU packet.
*
* Read differences between Modbus RTU and Modbus TCP/IP packet http://www.simplymodbus.ca/TCP.htm
*
* Difference is:
* 1. RTU header contains only slave id. TCP/IP header contains of transaction id, protocol id, length, unitid
* 2. RTU packed has CRC16 appended
*
* NB: RTU slave id equivalent is TCP/IP packet unit id
*/
final class RtuConverter
{
private function __construct()
{
// utility class
}

/**
* Convert Modbus TCP request instance to Modbus RTU binary packet
*
* @param ModbusRequest $request request to be converted
* @return string Modbus RTU request in binary form
*/
public static function toRtu(ModbusRequest $request): string
{
// trim 6 bytes: 2 bytes for transaction id + 2 bytes for protocol id + 2 bytes for data length field
$packet = substr((string)$request, 6);
return $packet . self::crc16($packet);
}

/**
* Converts binary string containing RTU response packet to Modbus TCP response instance
*
* @param string $binaryData rtu binary response
* @param array $options option to use during conversion
* @return ModbusResponse converted Modbus TCP packet
* @throws \ModbusTcpClient\Exception\ParseException
* @throws \Exception if it was not possible to gather sufficient entropy
*/
public static function fromRtu(string $binaryData, array $options = []): ModbusResponse
{
$data = substr($binaryData, 0, -2); // remove and crc

if ((bool)($options['no_crc_check'] ?? false) === false) {
$originalCrc = substr($binaryData, -2);
$calculatedCrc = self::crc16($data);
if ($originalCrc !== $calculatedCrc) {
throw new ParseException(
sprintf('Packet crc (\x%s) does not match calculated crc (\x%s)!',
bin2hex($originalCrc),
bin2hex($calculatedCrc)
)
);
}
}

$packet = b''
. Types::toRegister(random_int(0, Types::MAX_VALUE_UINT16)) // 2 bytes for transaction id
. "\x00\x00" // 2 bytes for protocol id
. Types::toRegister(strlen($data)) // 2 bytes for data length field
. $data;

return ResponseFactory::parseResponse($packet);
}

private static function crc16(string $string): string
{
$crc = 0xFFFF;
for ($x = 0, $xMax = \strlen($string); $x < $xMax; $x++) {
$crc ^= \ord($string[$x]);
for ($y = 0; $y < 8; $y++) {
if (($crc & 0x0001) === 0x0001) {
$crc = (($crc >> 1) ^ 0xA001);
} else {
$crc >>= 1;
}
}
}

return \chr($crc & 0xFF) . \chr($crc >> 8);
}
}
4 changes: 2 additions & 2 deletions tests/unit/Composer/Read/BitAddressTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public function testDefaultGetName()

public function testExtract()
{
$responsePacket = new ReadHoldingRegistersResponse("\x01\x00\x05", 3, 33152);
$responsePacket = new ReadHoldingRegistersResponse("\x02\x00\x05", 3, 33152);

$this->assertTrue((new BitReadAddress(0, 0))->extract($responsePacket));
$this->assertFalse((new BitReadAddress(0, 1))->extract($responsePacket));
Expand All @@ -38,7 +38,7 @@ public function testExtract()

public function testExtractWithCallback()
{
$responsePacket = new ReadHoldingRegistersResponse("\x01\x00\x05", 3, 33152);
$responsePacket = new ReadHoldingRegistersResponse("\x02\x00\x05", 3, 33152);

$address = new BitReadAddress(0, 0, 'name', function ($value) {
return 'prefix_' . $value; // transform value after extraction
Expand Down
4 changes: 2 additions & 2 deletions tests/unit/Composer/Read/ByteAddressTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,15 @@ public function testDefaultGetName()

public function testExtract()
{
$responsePacket = new ReadHoldingRegistersResponse("\x01\x00\x05", 3, 33152);
$responsePacket = new ReadHoldingRegistersResponse("\x02\x00\x05", 3, 33152);

$this->assertEquals(5, (new ByteReadAddress(0, true))->extract($responsePacket));
$this->assertEquals(0, (new ByteReadAddress(0, false))->extract($responsePacket));
}

public function testExtractWithCallback()
{
$responsePacket = new ReadHoldingRegistersResponse("\x01\x00\x05", 3, 33152);
$responsePacket = new ReadHoldingRegistersResponse("\x02\x00\x05", 3, 33152);

$address = new ByteReadAddress(0, true, null, function ($data) {
return 'prefix_' . $data;
Expand Down
9 changes: 9 additions & 0 deletions tests/unit/Packet/ModbusFunction/ReadCoilsResponseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -136,4 +136,13 @@ public function testOffsetGetOutOfBoundsOver()
$packet[66];
}

/**
* @expectedException \ModbusTcpClient\Exception\ParseException
* @expectedExceptionMessage packet byte count does not match bytes in packet! count: 3, actual: 2
*/
public function testFailWhenByteCountDoesNotMatch()
{
new ReadCoilsResponse("\x03\xCD\x6B", 3, 33152);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

namespace Tests\Packet\ModbusFunction;

use ModbusTcpClient\Packet\ModbusPacket;
use ModbusTcpClient\Packet\ModbusFunction\ReadHoldingRegistersResponse;
use ModbusTcpClient\Packet\ModbusPacket;
use PHPUnit\Framework\TestCase;

class ReadHoldingRegistersResponseTest extends TestCase
Expand Down Expand Up @@ -129,7 +129,7 @@ public function testAsDoubleWords()
$dWordsAssoc[$address] = $doubleWord;
}

$dWords =[];
$dWords = [];
foreach ($packet->asDoubleWords() as $doubleWord) {
$dWords[] = $doubleWord;
}
Expand Down Expand Up @@ -180,6 +180,15 @@ public function testOffsetUnSet()
unset($packet[50]);
}

/**
* @expectedException \ModbusTcpClient\Exception\ParseException
* @expectedExceptionMessage packet byte count does not match bytes in packet! count: 6, actual: 7
*/
public function testFailWhenByteCountDoesNotMatch()
{
new ReadHoldingRegistersResponse("\x06\xCD\x6B\x0\x0\x0\x01\x00", 3, 33152);
}

public function testOffsetGet()
{
$packet = (new ReadHoldingRegistersResponse(
Expand Down Expand Up @@ -234,7 +243,7 @@ public function testOffsetGetOutOfBoundsOver()
33152
))->withStartAddress(50);

$packet[53];
$packet[53];
}

/**
Expand Down Expand Up @@ -341,7 +350,7 @@ public function testGetAsciiString()
$packet = (new ReadHoldingRegistersResponse("\x08\x01\x00\xF8\x53\x65\x72\x00\x6E", 3, 33152))->withStartAddress(50);
$this->assertCount(4, $packet->getWords());

$this->assertEquals('Søren', $packet->getAsciiStringAt(51,5));
$this->assertEquals('Søren', $packet->getAsciiStringAt(51, 5));
}

/**
Expand All @@ -353,7 +362,7 @@ public function testGetAsciiStringInvalidAddressLow()
$packet = (new ReadHoldingRegistersResponse("\x08\x01\x00\xF8\x53\x65\x72\x00\x6E", 3, 33152))->withStartAddress(50);
$this->assertCount(4, $packet->getWords());

$packet->getAsciiStringAt(49,5);
$packet->getAsciiStringAt(49, 5);
}

/**
Expand All @@ -365,7 +374,7 @@ public function testGetAsciiStringInvalidAddressHigh()
$packet = (new ReadHoldingRegistersResponse("\x08\x01\x00\xF8\x53\x65\x72\x00\x6E", 3, 33152))->withStartAddress(50);
$this->assertCount(4, $packet->getWords());

$packet->getAsciiStringAt(54,5);
$packet->getAsciiStringAt(54, 5);
}

/**
Expand All @@ -377,6 +386,6 @@ public function testGetAsciiStringInvalidLength()
$packet = (new ReadHoldingRegistersResponse("\x08\x01\x00\xF8\x53\x65\x72\x00\x6E", 3, 33152))->withStartAddress(50);
$this->assertCount(4, $packet->getWords());

$packet->getAsciiStringAt(50,0);
$packet->getAsciiStringAt(50, 0);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@

namespace Tests\Packet\ModbusFunction;

use ModbusTcpClient\Packet\ModbusPacket;
use ModbusTcpClient\Packet\ModbusFunction\ReadCoilsResponse;
use ModbusTcpClient\Packet\ModbusFunction\ReadInputDiscretesResponse;
use ModbusTcpClient\Packet\ModbusPacket;
use PHPUnit\Framework\TestCase;

class ReadInputDiscretesResponseTest extends TestCase
Expand Down Expand Up @@ -34,4 +33,13 @@ public function testPacketProperties()
$this->assertEquals(3, $header->getUnitId());
}

/**
* @expectedException \ModbusTcpClient\Exception\ParseException
* @expectedExceptionMessage packet byte count does not match bytes in packet! count: 3, actual: 2
*/
public function testFailWhenByteCountDoesNotMatch()
{
new ReadInputDiscretesResponse("\x03\xCD\x6B", 3, 33152);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@

namespace Tests\Packet\ModbusFunction;

use ModbusTcpClient\Packet\ModbusPacket;
use ModbusTcpClient\Packet\ModbusFunction\ReadCoilsResponse;
use ModbusTcpClient\Packet\ModbusFunction\ReadInputDiscretesResponse;
use ModbusTcpClient\Packet\ModbusFunction\ReadInputRegistersResponse;
use ModbusTcpClient\Packet\ModbusPacket;
use PHPUnit\Framework\TestCase;

class ReadInputRegistersResponseTest extends TestCase
Expand All @@ -32,4 +30,13 @@ public function testPacketProperties()
$this->assertEquals(3, $header->getUnitId());
}

/**
* @expectedException \ModbusTcpClient\Exception\ParseException
* @expectedExceptionMessage packet byte count does not match bytes in packet! count: 3, actual: 2
*/
public function testFailWhenByteCountDoesNotMatch()
{
new ReadInputRegistersResponse("\x03\xCD\x6B", 3, 33152);
}

}
Loading

0 comments on commit f3d4481

Please sign in to comment.