From 506fde5bf39984f4e8519a92a1c4014c08140aa6 Mon Sep 17 00:00:00 2001 From: toimtoimtoim Date: Tue, 31 Mar 2020 22:40:37 +0300 Subject: [PATCH] add write coils api with request splitter --- src/Composer/Read/ReadCoilAddress.php | 5 + src/Composer/Write/WriteCoilAddress.php | 41 +++++ .../Write/WriteCoilAddressSplitter.php | 68 +++++++ src/Composer/Write/WriteCoilRequest.php | 68 +++++++ src/Composer/Write/WriteCoilsBuilder.php | 112 ++++++++++++ .../Read/ReadCoilAddressSplitterTest.php | 28 +++ .../Write/WriteCoilAddressSplitterTest.php | 31 ++++ .../Composer/Write/WriteCoilRequestTest.php | 53 ++++++ .../Composer/Write/WriteCoilsBuilderTest.php | 169 ++++++++++++++++++ 9 files changed, 575 insertions(+) create mode 100644 src/Composer/Write/WriteCoilAddress.php create mode 100644 src/Composer/Write/WriteCoilAddressSplitter.php create mode 100644 src/Composer/Write/WriteCoilRequest.php create mode 100644 src/Composer/Write/WriteCoilsBuilder.php create mode 100644 tests/unit/Composer/Read/ReadCoilAddressSplitterTest.php create mode 100644 tests/unit/Composer/Write/WriteCoilAddressSplitterTest.php create mode 100644 tests/unit/Composer/Write/WriteCoilRequestTest.php create mode 100644 tests/unit/Composer/Write/WriteCoilsBuilderTest.php diff --git a/src/Composer/Read/ReadCoilAddress.php b/src/Composer/Read/ReadCoilAddress.php index 549cef9..1ea55bc 100644 --- a/src/Composer/Read/ReadCoilAddress.php +++ b/src/Composer/Read/ReadCoilAddress.php @@ -65,4 +65,9 @@ public function getAddress(): int { return $this->address; } + + public function getType(): string + { + return Address::TYPE_BIT; + } } diff --git a/src/Composer/Write/WriteCoilAddress.php b/src/Composer/Write/WriteCoilAddress.php new file mode 100644 index 0000000..b7a63c3 --- /dev/null +++ b/src/Composer/Write/WriteCoilAddress.php @@ -0,0 +1,41 @@ +address = $address; + $this->value = $value; + } + + public function getValue(): bool + { + return $this->value; + } + + public function getSize(): int + { + return 1; + } + + public function getAddress(): int + { + return $this->address; + } + + public function getType(): string + { + return Address::TYPE_BIT; + } +} diff --git a/src/Composer/Write/WriteCoilAddressSplitter.php b/src/Composer/Write/WriteCoilAddressSplitter.php new file mode 100644 index 0000000..7c7f231 --- /dev/null +++ b/src/Composer/Write/WriteCoilAddressSplitter.php @@ -0,0 +1,68 @@ +requestClass = $requestClass; + } + + /** + * @param string $uri + * @param WriteCoilAddress[] $addressesChunk + * @param int $startAddress + * @param int $quantity + * @param int $unitId + * @return WriteCoilRequest + */ + protected function createRequest(string $uri, array $addressesChunk, int $startAddress, int $quantity, int $unitId = 0) + { + $values = []; + foreach ($addressesChunk as $address) { + $values[] = $address->getValue(); + } + + return new WriteCoilRequest($uri, $addressesChunk, new $this->requestClass($startAddress, $values, $unitId)); + } + + protected function getMaxAddressesPerModbusRequest(): int + { + return static::MAX_COILS_PER_MODBUS_REQUEST; + } + + protected function shouldSplit(Address $currentAddress, int $currentQuantity, Address $previousAddress = null, int $previousQuantity = null): bool + { + $isOverAddressLimit = $currentQuantity >= $this->getMaxAddressesPerModbusRequest(); + if ($isOverAddressLimit) { + return $isOverAddressLimit; + } + if ($previousAddress === null) { + return false; + } + + $currentStartAddress = $currentAddress->getAddress(); + $previousStartAddress = $previousAddress->getAddress(); + $previousAddressEndStartAddress = ($previousStartAddress + $previousAddress->getSize()); + + if (($previousStartAddress <= $currentStartAddress) && ($currentStartAddress < $previousAddressEndStartAddress)) { + // situation when current address overlaps previous memory range does not make sense + + $info = "{$previousStartAddress} with {$currentStartAddress}"; + throw new InvalidArgumentException('Trying to write addresses that seem share their memory range! ' . $info); + } + + // current and previous need to be adjacent as WriteMultipleCoilsRequest needs to have all registers in packet to be adjacent + // or another packet should be build (split) + return $currentStartAddress - $previousAddressEndStartAddress > 0; + } +} diff --git a/src/Composer/Write/WriteCoilRequest.php b/src/Composer/Write/WriteCoilRequest.php new file mode 100644 index 0000000..04a49aa --- /dev/null +++ b/src/Composer/Write/WriteCoilRequest.php @@ -0,0 +1,68 @@ +request = $request; + $this->uri = $uri; + $this->addresses = $addresses; + } + + /** + * @return ModbusRequest + */ + public function getRequest() + { + return $this->request; + } + + public function getUri(): string + { + return $this->uri; + } + + /** + * @return WriteCoilAddress[] + */ + public function getAddresses(): array + { + return $this->addresses; + } + + public function __toString() + { + return $this->request->__toString(); + } + + /** + * @param string $binaryData + * @return ModbusResponse + * @throws ModbusException + */ + public function parse(string $binaryData): ModbusResponse + { + return ResponseFactory::parseResponse($binaryData); + } +} diff --git a/src/Composer/Write/WriteCoilsBuilder.php b/src/Composer/Write/WriteCoilsBuilder.php new file mode 100644 index 0000000..f7b0f51 --- /dev/null +++ b/src/Composer/Write/WriteCoilsBuilder.php @@ -0,0 +1,112 @@ +addressSplitter = new WriteCoilAddressSplitter($requestClass); + + if ($uri !== null) { + $this->useUri($uri); + } + $this->unitId = $unitId; + } + + public static function newWriteMultipleCoils(string $uri = null, int $unitId = 0): WriteCoilsBuilder + { + return new WriteCoilsBuilder(WriteMultipleCoilsRequest::class, $uri, $unitId); + } + + public function useUri(string $uri, int $unitId = 0): WriteCoilsBuilder + { + if (empty($uri)) { + throw new InvalidArgumentException('uri can not be empty value'); + } + $this->currentUri = $uri; + $this->unitId = $unitId; + + return $this; + } + + protected function addAddress(WriteCoilAddress $address): WriteCoilsBuilder + { + if (empty($this->currentUri)) { + throw new InvalidArgumentException('uri not set'); + } + $unitIdPrefix = AddressSplitter::UNIT_ID_PREFIX; + $modbusPath = "{$this->currentUri}{$unitIdPrefix}{$this->unitId}"; + $this->addresses[$modbusPath][$address->getAddress()] = $address; + return $this; + } + + public function allFromArray(array $coils): WriteCoilsBuilder + { + foreach ($coils as $coil) { + if (\is_array($coil)) { + $this->fromArray($coil); + } elseif ($coil instanceof WriteCoilAddress) { + $this->addAddress($coil); + } + } + return $this; + } + + public function fromArray(array $coil): WriteCoilsBuilder + { + $uri = $coil['uri'] ?? null; + $unitId = $coil['unitId'] ?? 0; + if ($uri !== null) { + $this->useUri($uri, $unitId); + } + + $address = $coil['address'] ?? null; + if ($address === null) { + throw new InvalidArgumentException('empty address given'); + } + + if (!array_key_exists('value', $coil)) { + throw new InvalidArgumentException('value missing'); + } + + $this->coil($address, (bool)$coil['value']); + + return $this; + } + + public function coil(int $address, bool $value): WriteCoilsBuilder + { + return $this->addAddress(new WriteCoilAddress($address, $value)); + } + + /** + * @return WriteCoilRequest[] + */ + public function build(): array + { + return $this->addressSplitter->split($this->addresses); + } + + public function isNotEmpty() + { + return !empty($this->addresses); + } +} diff --git a/tests/unit/Composer/Read/ReadCoilAddressSplitterTest.php b/tests/unit/Composer/Read/ReadCoilAddressSplitterTest.php new file mode 100644 index 0000000..e8b51fb --- /dev/null +++ b/tests/unit/Composer/Read/ReadCoilAddressSplitterTest.php @@ -0,0 +1,28 @@ +split([ + 'tcp://127.0.0.1' . AddressSplitter::UNIT_ID_PREFIX . '1' => [ + new ReadCoilAddress(256), + new ReadCoilAddress(256), + ] + ]); + + $this->assertCount(1, $requests); + $this->assertCount(2, $requests[0]->getAddresses()); + } +} diff --git a/tests/unit/Composer/Write/WriteCoilAddressSplitterTest.php b/tests/unit/Composer/Write/WriteCoilAddressSplitterTest.php new file mode 100644 index 0000000..d1685d0 --- /dev/null +++ b/tests/unit/Composer/Write/WriteCoilAddressSplitterTest.php @@ -0,0 +1,31 @@ +split([ + 'tcp://127.0.0.1' . AddressSplitter::UNIT_ID_PREFIX . '1' => [ + new WriteCoilAddress(256, true), + new WriteCoilAddress(256, false), + ] + ]); + + $this->assertEquals(1, $requests); + } +} diff --git a/tests/unit/Composer/Write/WriteCoilRequestTest.php b/tests/unit/Composer/Write/WriteCoilRequestTest.php new file mode 100644 index 0000000..5e0a0c5 --- /dev/null +++ b/tests/unit/Composer/Write/WriteCoilRequestTest.php @@ -0,0 +1,53 @@ +getValue()]); + + $writeRequest = new WriteCoilRequest($uri, $addresses, $request); + + $this->assertEquals($uri, $writeRequest->getUri()); + $this->assertEquals($request, $writeRequest->getRequest()); + $this->assertEquals($addresses, $writeRequest->getAddresses()); + } + + public function testToString() + { + $uri = 'tcp://192.168.100.1:502'; + $addresses = [new WriteCoilAddress(0, true)]; + $request = new WriteMultipleCoilsRequest(1, [$addresses[0]->getValue()]); + + $writeRequest = new WriteCoilRequest($uri, $addresses, $request); + + $this->assertEquals($request->__toString(), $writeRequest->__toString()); + } + + public function testParse() + { + $request = new WriteMultipleCoilsRequest(1, [true]); + + $writeRequest = new WriteCoilRequest('tcp://192.168.100.1:502', [], $request); + + $value = $writeRequest->parse("\x01\x38\x00\x00\x00\x06\x11\x0F\x04\x10\x00\x03"); + $this->assertInstanceOf(WriteMultipleCoilsResponse::class, $value); + } + +} diff --git a/tests/unit/Composer/Write/WriteCoilsBuilderTest.php b/tests/unit/Composer/Write/WriteCoilsBuilderTest.php new file mode 100644 index 0000000..a7c0c80 --- /dev/null +++ b/tests/unit/Composer/Write/WriteCoilsBuilderTest.php @@ -0,0 +1,169 @@ +coil(278, true) + ->coil(280, false) + ->build(); + + $this->assertCount(2, $requests); + } + + public function testBuildSplitRequestTo3() + { + $requests = WriteCoilsBuilder::newWriteMultipleCoils('tcp://127.0.0.1:5022') + ->coil(278, true) + ->coil(279, true) + ->coil(280, true) + ->coil(281, false) + // will be split into 2 requests as 1 request can return only range of 124 registers max + ->coil(450 + AddressSplitter::MAX_COILS_PER_MODBUS_REQUEST, true) + ->coil(451 + AddressSplitter::MAX_COILS_PER_MODBUS_REQUEST, true) + // will be another request as uri is different for subsequent string register + ->useUri('tcp://127.0.0.1:5023') + ->coil(270, true) + ->coil(271, true) + ->build(); + + $this->assertCount(3, $requests); + + $writeRequest = $requests[0]; + $this->assertInstanceOf(WriteMultipleCoilsRequest::class, $writeRequest->getRequest()); + $this->assertEquals('tcp://127.0.0.1:5022', $writeRequest->getUri()); + $this->assertCount(4, $writeRequest->getAddresses()); + + $writeRequest1 = $requests[1]; + $this->assertInstanceOf(WriteMultipleCoilsRequest::class, $writeRequest1->getRequest()); + $this->assertEquals('tcp://127.0.0.1:5022', $writeRequest1->getUri()); + $this->assertCount(2, $writeRequest1->getAddresses()); + + $writeRequest2 = $requests[2]; + $this->assertInstanceOf(WriteMultipleCoilsRequest::class, $writeRequest2->getRequest()); + $this->assertEquals('tcp://127.0.0.1:5023', $writeRequest2->getUri()); + $this->assertCount(2, $writeRequest2->getAddresses()); + } + + public function testBuildAllFromArray() + { + $requests = WriteCoilsBuilder::newWriteMultipleCoils('tcp://127.0.0.1:5022') + ->allFromArray([ + ['uri' => 'tcp://127.0.0.1:5022', 'value' => true, 'address' => 0], + // will be split into 2 requests as 1 request can return only range of 2048 coils max + ['uri' => 'tcp://127.0.0.1:5022', 'value' => true, 'address' => 453], + ['uri' => 'tcp://127.0.0.1:5022', 'value' => true, 'address' => 454], + // will be another request as uri is different for subsequent coils + ['uri' => 'tcp://127.0.0.1:5023', 'value' => true, 'address' => 270], + ['uri' => 'tcp://127.0.0.1:5023', 'value' => true, 'address' => 271], + ['uri' => 'tcp://127.0.0.1:5023', 'value' => false, 'address' => 272], + ['uri' => 'tcp://127.0.0.1:5023', 'value' => true, 'address' => 273], + ['uri' => 'tcp://127.0.0.1:5023', 'value' => true, 'address' => 274], + ['uri' => 'tcp://127.0.0.1:5023', 'value' => true, 'address' => 275], + ['uri' => 'tcp://127.0.0.1:5023', 'value' => true, 'address' => 276], + ]) + ->build(); + + $this->assertCount(3, $requests); + + $this->assertCount(1, $requests[0]->getAddresses()); + $this->assertCount(2, $requests[1]->getAddresses()); + $this->assertCount(7, $requests[2]->getAddresses()); + } + + public function testBuildAllFromArrayUsingObject() + { + $requests = WriteCoilsBuilder::newWriteMultipleCoils('tcp://127.0.0.1:5022') + ->allFromArray([new WriteCoilAddress(256, true)])->build(); + + $this->assertCount(1, $requests); + $this->assertCount(1, $requests[0]->getAddresses()); + } + + public function testBuildAllowsForOverlappingCoils() + { + $requests = WriteCoilsBuilder::newWriteMultipleCoils('tcp://127.0.0.1:5022') + ->allFromArray([ + new WriteCoilAddress(256, true), + new WriteCoilAddress(256, false), + ])->build(); + + $this->assertCount(1, $requests); + $this->assertCount(1, $requests[0]->getAddresses()); + } + + /** + * @expectedException \ModbusTcpClient\Exception\InvalidArgumentException + * @expectedExceptionMessage empty address given + */ + public function testBuildgMissingAddress() + { + WriteCoilsBuilder::newWriteMultipleCoils('tcp://127.0.0.1:5022') + ->fromArray([ + 'uri' => 'tcp://127.0.0.1:5022', + 'value' => true, + ])->build(); + } + + /** + * @expectedException \ModbusTcpClient\Exception\InvalidArgumentException + * @expectedExceptionMessage value missing + */ + public function testBuildMissingValue() + { + WriteCoilsBuilder::newWriteMultipleCoils('tcp://127.0.0.1:5022') + ->fromArray([ + 'uri' => 'tcp://127.0.0.1:5022', + 'address' => 256, + ])->build(); + } + + /** + * @expectedException \ModbusTcpClient\Exception\InvalidArgumentException + * @expectedExceptionMessage uri not set + */ + public function testCanNotAddWithoutUri() + { + WriteCoilsBuilder::newWriteMultipleCoils() + ->coil(278, true) + ->build(); + } + + /** + * @expectedException \ModbusTcpClient\Exception\InvalidArgumentException + * @expectedExceptionMessage uri can not be empty value + */ + public function testCanNotSetEmptyUri() + { + WriteCoilsBuilder::newWriteMultipleCoils() + ->useUri('') + ->build(); + } + + public function testIsNotEmptyTrue() + { + $builder = WriteCoilsBuilder::newWriteMultipleCoils('tcp://127.0.0.1:5022') + ->coil(278, true); + + $this->assertTrue($builder->isNotEmpty()); + } + + public function testIsNotEmptyFalse() + { + $builder = WriteCoilsBuilder::newWriteMultipleCoils('tcp://127.0.0.1:5022'); + + $this->assertFalse($builder->isNotEmpty()); + } + +}