From 10093160be0805ddc4f85cb1ff2ed1af86e423c5 Mon Sep 17 00:00:00 2001 From: toimtoimtoim Date: Sat, 14 Jul 2018 23:48:37 +0300 Subject: [PATCH 1/2] add support to Modbus RTU --- examples/rtu.php | 35 ++++++++++++ src/Packet/RtuConverter.php | 74 ++++++++++++++++++++++++++ tests/unit/Packet/RtuConverterTest.php | 59 ++++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 examples/rtu.php create mode 100644 src/Packet/RtuConverter.php create mode 100644 tests/unit/Packet/RtuConverterTest.php diff --git a/examples/rtu.php b/examples/rtu.php new file mode 100644 index 0000000..247248d --- /dev/null +++ b/examples/rtu.php @@ -0,0 +1,35 @@ +setPort(502) + ->setHost('127.0.0.1') + ->build(); + +$startAddress = 256; +$quantity = 6; +$slaveId = 1; // RTU packet slave id equivalent is Modbus TCP unitId + +$rtuPacket = RtuConverter::toRtu(new ReadHoldingRegistersRequest($startAddress, $quantity, $slaveId)); + +try { + $binaryData = $connection->connect()->sendAndReceive($rtuPacket); + echo '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(); +} diff --git a/src/Packet/RtuConverter.php b/src/Packet/RtuConverter.php new file mode 100644 index 0000000..49a3de4 --- /dev/null +++ b/src/Packet/RtuConverter.php @@ -0,0 +1,74 @@ +> 1) ^ 0xA001); + } else { + $crc >>= 1; + } + } + } + + return \chr($crc & 0xFF) . \chr($crc >> 8); + } +} \ No newline at end of file diff --git a/tests/unit/Packet/RtuConverterTest.php b/tests/unit/Packet/RtuConverterTest.php new file mode 100644 index 0000000..6b06ac2 --- /dev/null +++ b/tests/unit/Packet/RtuConverterTest.php @@ -0,0 +1,59 @@ +assertEquals("\x11\x03\x00\x6B\x00\x03\x76\x87", $rtuBinary); + } + + public function testPacketfromRtu() + { + /** @var ReadHoldingRegistersRequest $packet */ + $packet = RtuConverter::fromRtu("\x03\x03\x02\xCD\x6B\xD4\xFB"); + + $this->assertInstanceOf(ReadHoldingRegistersResponse::class, $packet); + + $tcpPacket = new ReadHoldingRegistersResponse("\x02\xCD\x6B", 3, $packet->getHeader()->getTransactionId()); + $this->assertEquals($packet, $tcpPacket); + } + + public function testExceptionPacketfromRtu() + { + /** @var ErrorResponse $packet */ + $packet = RtuConverter::fromRtu("\x00\x81\x03\x51\x91"); + + $this->assertInstanceOf(ErrorResponse::class, $packet); + + $tcpPacket = new ErrorResponse( + new ModbusApplicationHeader(3, 0, $packet->getHeader()->getTransactionId()) + , 1 + , 3 + ); + $this->assertEquals($packet, $tcpPacket); + } + + /** + * @expectedException \ModbusTcpClient\Exception\ParseException + * @expectedExceptionMessage Packet crc (\x5190) does not match calculated crc (\x5191)! + */ + public function testRtuPackWithInvalidCrc() + { + RtuConverter::fromRtu("\x00\x81\x03\x51\x90"); + } + +} \ No newline at end of file From b504456a6e8d5afaadaa86894cd49945c28f206d Mon Sep 17 00:00:00 2001 From: toimtoimtoim Date: Sat, 28 Jul 2018 19:41:52 +0300 Subject: [PATCH 2/2] Update readme about RTU, improve response error checks, add option to not check crc for RTU packets --- README.md | 19 +++++++++- examples/rtu.php | 6 ++- src/Packet/ByteCountResponse.php | 7 ++++ .../WriteMultipleCoilsResponse.php | 2 +- .../WriteMultipleRegistersResponse.php | 2 +- src/Packet/RtuConverter.php | 37 ++++++++++++++----- tests/unit/Composer/Read/BitAddressTest.php | 4 +- tests/unit/Composer/Read/ByteAddressTest.php | 4 +- .../ModbusFunction/ReadCoilsResponseTest.php | 9 +++++ .../ReadHoldingRegistersResponseTest.php | 23 ++++++++---- .../ReadInputDiscretesResponseTest.php | 12 +++++- .../ReadInputRegistersResponseTest.php | 13 +++++-- tests/unit/Packet/RtuConverterTest.php | 12 ++++++ 13 files changed, 118 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 24cea1d..48963b7 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 @@ -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 diff --git a/examples/rtu.php b/examples/rtu.php index 247248d..2319fa2 100644 --- a/examples/rtu.php +++ b/examples/rtu.php @@ -9,17 +9,19 @@ $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 -$rtuPacket = RtuConverter::toRtu(new ReadHoldingRegistersRequest($startAddress, $quantity, $slaveId)); +$tcpPacket = new ReadHoldingRegistersRequest($startAddress, $quantity, $slaveId); +$rtuPacket = RtuConverter::toRtu($tcpPacket); try { $binaryData = $connection->connect()->sendAndReceive($rtuPacket); - echo 'Binary received (in hex): ' . unpack('H*', $binaryData)[1] . PHP_EOL; + 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; diff --git a/src/Packet/ByteCountResponse.php b/src/Packet/ByteCountResponse.php index 47ea5bd..165ca14 100644 --- a/src/Packet/ByteCountResponse.php +++ b/src/Packet/ByteCountResponse.php @@ -3,6 +3,7 @@ namespace ModbusTcpClient\Packet; +use ModbusTcpClient\Exception\ParseException; use ModbusTcpClient\Utils\Types; abstract class ByteCountResponse extends ProtocolDataUnit implements ModbusResponse @@ -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); } diff --git a/src/Packet/ModbusFunction/WriteMultipleCoilsResponse.php b/src/Packet/ModbusFunction/WriteMultipleCoilsResponse.php index 43d8fcd..c1b6993 100644 --- a/src/Packet/ModbusFunction/WriteMultipleCoilsResponse.php +++ b/src/Packet/ModbusFunction/WriteMultipleCoilsResponse.php @@ -13,7 +13,7 @@ class WriteMultipleCoilsResponse extends StartAddressResponse { /** - * @var int + * @var int coils written */ private $coilCount; diff --git a/src/Packet/ModbusFunction/WriteMultipleRegistersResponse.php b/src/Packet/ModbusFunction/WriteMultipleRegistersResponse.php index 32128a3..037d139 100644 --- a/src/Packet/ModbusFunction/WriteMultipleRegistersResponse.php +++ b/src/Packet/ModbusFunction/WriteMultipleRegistersResponse.php @@ -13,7 +13,7 @@ class WriteMultipleRegistersResponse extends StartAddressResponse { /** - * @var int + * @var int number of registers written */ private $registersCount; diff --git a/src/Packet/RtuConverter.php b/src/Packet/RtuConverter.php index 49a3de4..e6ae36c 100644 --- a/src/Packet/RtuConverter.php +++ b/src/Packet/RtuConverter.php @@ -24,6 +24,12 @@ 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 @@ -31,19 +37,30 @@ public static function toRtu(ModbusRequest $request): string return $packet . self::crc16($packet); } - public static function fromRtu(string $binaryData): ModbusResponse + /** + * 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 - $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) - ) - ); + 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'' diff --git a/tests/unit/Composer/Read/BitAddressTest.php b/tests/unit/Composer/Read/BitAddressTest.php index bbef2ea..c954b61 100644 --- a/tests/unit/Composer/Read/BitAddressTest.php +++ b/tests/unit/Composer/Read/BitAddressTest.php @@ -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)); @@ -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 diff --git a/tests/unit/Composer/Read/ByteAddressTest.php b/tests/unit/Composer/Read/ByteAddressTest.php index 3be2a66..45c813d 100644 --- a/tests/unit/Composer/Read/ByteAddressTest.php +++ b/tests/unit/Composer/Read/ByteAddressTest.php @@ -33,7 +33,7 @@ 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)); @@ -41,7 +41,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 ByteReadAddress(0, true, null, function ($data) { return 'prefix_' . $data; diff --git a/tests/unit/Packet/ModbusFunction/ReadCoilsResponseTest.php b/tests/unit/Packet/ModbusFunction/ReadCoilsResponseTest.php index fe2dd1a..8b742df 100644 --- a/tests/unit/Packet/ModbusFunction/ReadCoilsResponseTest.php +++ b/tests/unit/Packet/ModbusFunction/ReadCoilsResponseTest.php @@ -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); + } + } \ No newline at end of file diff --git a/tests/unit/Packet/ModbusFunction/ReadHoldingRegistersResponseTest.php b/tests/unit/Packet/ModbusFunction/ReadHoldingRegistersResponseTest.php index 2c8395f..d22651d 100644 --- a/tests/unit/Packet/ModbusFunction/ReadHoldingRegistersResponseTest.php +++ b/tests/unit/Packet/ModbusFunction/ReadHoldingRegistersResponseTest.php @@ -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 @@ -129,7 +129,7 @@ public function testAsDoubleWords() $dWordsAssoc[$address] = $doubleWord; } - $dWords =[]; + $dWords = []; foreach ($packet->asDoubleWords() as $doubleWord) { $dWords[] = $doubleWord; } @@ -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( @@ -234,7 +243,7 @@ public function testOffsetGetOutOfBoundsOver() 33152 ))->withStartAddress(50); - $packet[53]; + $packet[53]; } /** @@ -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)); } /** @@ -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); } /** @@ -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); } /** @@ -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); } } \ No newline at end of file diff --git a/tests/unit/Packet/ModbusFunction/ReadInputDiscretesResponseTest.php b/tests/unit/Packet/ModbusFunction/ReadInputDiscretesResponseTest.php index 3c0cdc3..f11cb68 100644 --- a/tests/unit/Packet/ModbusFunction/ReadInputDiscretesResponseTest.php +++ b/tests/unit/Packet/ModbusFunction/ReadInputDiscretesResponseTest.php @@ -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 @@ -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); + } + } \ No newline at end of file diff --git a/tests/unit/Packet/ModbusFunction/ReadInputRegistersResponseTest.php b/tests/unit/Packet/ModbusFunction/ReadInputRegistersResponseTest.php index 1ce3a5e..7938c58 100644 --- a/tests/unit/Packet/ModbusFunction/ReadInputRegistersResponseTest.php +++ b/tests/unit/Packet/ModbusFunction/ReadInputRegistersResponseTest.php @@ -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 @@ -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); + } + } \ No newline at end of file diff --git a/tests/unit/Packet/RtuConverterTest.php b/tests/unit/Packet/RtuConverterTest.php index 6b06ac2..8a453c8 100644 --- a/tests/unit/Packet/RtuConverterTest.php +++ b/tests/unit/Packet/RtuConverterTest.php @@ -7,6 +7,7 @@ use ModbusTcpClient\Packet\ModbusApplicationHeader; use ModbusTcpClient\Packet\ModbusFunction\ReadHoldingRegistersRequest; use ModbusTcpClient\Packet\ModbusFunction\ReadHoldingRegistersResponse; +use ModbusTcpClient\Packet\ModbusFunction\ReadInputRegistersResponse; use ModbusTcpClient\Packet\RtuConverter; use PHPUnit\Framework\TestCase; @@ -56,4 +57,15 @@ public function testRtuPackWithInvalidCrc() RtuConverter::fromRtu("\x00\x81\x03\x51\x90"); } + public function testRtuPackWithInvalidCrcIsRead() + { + /** @var ReadHoldingRegistersRequest $packet */ + $packet = RtuConverter::fromRtu("\x03\x03\x02\xCD\x6B\x00\x00", ['no_crc_check' => true]); // last 2 bytes for crc should be \xD4\xFB to be correct + + $this->assertInstanceOf(ReadHoldingRegistersResponse::class, $packet); + + $tcpPacket = new ReadHoldingRegistersResponse("\x02\xCD\x6B", 3, $packet->getHeader()->getTransactionId()); + $this->assertEquals($packet, $tcpPacket); + } + } \ No newline at end of file