From 407cb4178fe0acb717e9c3a4fd508885f264f55f Mon Sep 17 00:00:00 2001 From: Ciprian Pascu Date: Sun, 7 Jan 2024 11:34:09 +0200 Subject: [PATCH] S-Bus transport protocol. Derived from Modbus protocol --- bom/openhab-core/pom.xml | 6 + .../.classpath | 40 + .../.project | 23 + .../org.openhab.core.io.transport.sbus/NOTICE | 20 + .../bnd.bnd | 10 + .../pom.xml | 71 ++ .../io/transport/sbus/AsyncModbusFailure.java | 65 + .../transport/sbus/AsyncModbusReadResult.java | 93 ++ .../sbus/AsyncModbusWriteResult.java | 66 ++ .../core/io/transport/sbus/BitArray.java | 141 +++ .../io/transport/sbus/ModbusBitUtilities.java | 761 ++++++++++++ .../sbus/ModbusCommunicationInterface.java | 108 ++ .../io/transport/sbus/ModbusConstants.java | 143 +++ .../transport/sbus/ModbusFailureCallback.java | 31 + .../core/io/transport/sbus/ModbusManager.java | 49 + .../io/transport/sbus/ModbusReadCallback.java | 32 + .../sbus/ModbusReadFunctionCode.java | 28 + .../sbus/ModbusReadRequestBlueprint.java | 139 +++ .../transport/sbus/ModbusRegisterArray.java | 138 +++ .../io/transport/sbus/ModbusResponse.java | 36 + .../transport/sbus/ModbusResultCallback.java | 24 + .../transport/sbus/ModbusWriteCallback.java | 32 + .../sbus/ModbusWriteCoilRequestBlueprint.java | 120 ++ .../sbus/ModbusWriteFunctionCode.java | 59 + .../ModbusWriteRegisterRequestBlueprint.java | 106 ++ .../sbus/ModbusWriteRequestBlueprint.java | 100 ++ .../ModbusWriteRequestBlueprintVisitor.java | 40 + .../core/io/transport/sbus/PollTask.java | 34 + .../io/transport/sbus/TaskWithEndpoint.java | 57 + .../core/io/transport/sbus/ValueBuffer.java | 331 ++++++ .../core/io/transport/sbus/WriteTask.java | 32 + .../endpoint/EndpointPoolConfiguration.java | 147 +++ .../sbus/endpoint/ModbusIPSlaveEndpoint.java | 71 ++ .../endpoint/ModbusSerialSlaveEndpoint.java | 100 ++ .../sbus/endpoint/ModbusSlaveEndpoint.java | 31 + .../endpoint/ModbusSlaveEndpointVisitor.java | 34 + .../sbus/endpoint/ModbusTCPSlaveEndpoint.java | 41 + .../sbus/endpoint/ModbusUDPSlaveEndpoint.java | 34 + .../exception/ModbusConnectionException.java | 57 + .../ModbusSlaveErrorResponseException.java | 162 +++ .../exception/ModbusSlaveIOException.java | 27 + .../exception/ModbusTransportException.java | 27 + ...expectedResponseFunctionCodeException.java | 48 + ...ModbusUnexpectedResponseSizeException.java | 47 + ...odbusUnexpectedTransactionIdException.java | 56 + .../sbus/internal/AggregateStopWatch.java | 72 ++ .../sbus/internal/BasicPollTask.java | 99 ++ .../sbus/internal/BasicWriteTask.java | 70 ++ .../sbus/internal/ModbusConnectionPool.java | 49 + .../sbus/internal/ModbusLibraryWrapper.java | 346 ++++++ .../sbus/internal/ModbusManagerImpl.java | 1042 ++++++++++++++++ .../sbus/internal/ModbusPoolConfig.java | 82 ++ .../sbus/internal/ModbusResponseImpl.java | 44 + ...ModbusSlaveErrorResponseExceptionImpl.java | 60 + .../internal/ModbusSlaveIOExceptionImpl.java | 56 + .../sbus/internal/SimpleStopWatch.java | 174 +++ .../ModbusSlaveConnectionEvictionPolicy.java | 35 + .../pooling/ModbusSlaveConnectionFactory.java | 28 + .../ModbusSlaveConnectionFactoryImpl.java | 370 ++++++ .../sbus/json/WriteRequestJsonUtilities.java | 216 ++++ .../sbus/test/AbstractRequestComparer.java | 72 ++ .../sbus/test/BasicBitArrayTest.java | 89 ++ .../BitUtilitiesCommandToRegistersTest.java | 347 ++++++ .../sbus/test/BitUtilitiesExtractBitTest.java | 136 +++ .../test/BitUtilitiesExtractFloat32Test.java | 82 ++ ...UtilitiesExtractIndividualMethodsTest.java | 266 +++++ .../test/BitUtilitiesExtractInt8Test.java | 115 ++ ...tilitiesExtractStateFromRegistersTest.java | 371 ++++++ .../test/BitUtilitiesExtractStringTest.java | 133 +++ ...UtilitiesTranslateCommand2BooleanTest.java | 81 ++ .../io/transport/sbus/test/CoilMatcher.java | 50 + .../sbus/test/IntegrationTestSupport.java | 354 ++++++ .../test/ModbusSlaveEndpointTestCase.java | 122 ++ ...usSlaveErrorResponseExceptionImplTest.java | 46 + .../transport/sbus/test/RegisterMatcher.java | 55 + .../io/transport/sbus/test/ResultCaptor.java | 56 + .../io/transport/sbus/test/SmokeTest.java | 1049 +++++++++++++++++ .../transport/sbus/test/ValueBufferTest.java | 188 +++ .../test/WriteRequestJsonUtilitiesTest.java | 227 ++++ .../openhab-core/src/main/feature/feature.xml | 12 + 80 files changed, 10411 insertions(+) create mode 100644 bundles/org.openhab.core.io.transport.sbus/.classpath create mode 100644 bundles/org.openhab.core.io.transport.sbus/.project create mode 100644 bundles/org.openhab.core.io.transport.sbus/NOTICE create mode 100644 bundles/org.openhab.core.io.transport.sbus/bnd.bnd create mode 100644 bundles/org.openhab.core.io.transport.sbus/pom.xml create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/AsyncModbusFailure.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/AsyncModbusReadResult.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/AsyncModbusWriteResult.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/BitArray.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusBitUtilities.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusCommunicationInterface.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusConstants.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusFailureCallback.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusManager.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusReadCallback.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusReadFunctionCode.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusReadRequestBlueprint.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusRegisterArray.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusResponse.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusResultCallback.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusWriteCallback.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusWriteCoilRequestBlueprint.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusWriteFunctionCode.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusWriteRegisterRequestBlueprint.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusWriteRequestBlueprint.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusWriteRequestBlueprintVisitor.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/PollTask.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/TaskWithEndpoint.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ValueBuffer.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/WriteTask.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/endpoint/EndpointPoolConfiguration.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/endpoint/ModbusIPSlaveEndpoint.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/endpoint/ModbusSerialSlaveEndpoint.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/endpoint/ModbusSlaveEndpoint.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/endpoint/ModbusSlaveEndpointVisitor.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/endpoint/ModbusTCPSlaveEndpoint.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/endpoint/ModbusUDPSlaveEndpoint.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/exception/ModbusConnectionException.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/exception/ModbusSlaveErrorResponseException.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/exception/ModbusSlaveIOException.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/exception/ModbusTransportException.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/exception/ModbusUnexpectedResponseFunctionCodeException.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/exception/ModbusUnexpectedResponseSizeException.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/exception/ModbusUnexpectedTransactionIdException.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/AggregateStopWatch.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/BasicPollTask.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/BasicWriteTask.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/ModbusConnectionPool.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/ModbusLibraryWrapper.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/ModbusManagerImpl.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/ModbusPoolConfig.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/ModbusResponseImpl.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/ModbusSlaveErrorResponseExceptionImpl.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/ModbusSlaveIOExceptionImpl.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/SimpleStopWatch.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/pooling/ModbusSlaveConnectionEvictionPolicy.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/pooling/ModbusSlaveConnectionFactory.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/pooling/ModbusSlaveConnectionFactoryImpl.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/json/WriteRequestJsonUtilities.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/AbstractRequestComparer.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BasicBitArrayTest.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesCommandToRegistersTest.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesExtractBitTest.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesExtractFloat32Test.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesExtractIndividualMethodsTest.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesExtractInt8Test.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesExtractStateFromRegistersTest.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesExtractStringTest.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesTranslateCommand2BooleanTest.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/CoilMatcher.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/IntegrationTestSupport.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/ModbusSlaveEndpointTestCase.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/ModbusSlaveErrorResponseExceptionImplTest.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/RegisterMatcher.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/ResultCaptor.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/SmokeTest.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/ValueBufferTest.java create mode 100644 bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/WriteRequestJsonUtilitiesTest.java diff --git a/bom/openhab-core/pom.xml b/bom/openhab-core/pom.xml index 786fc9df575..36945afb483 100644 --- a/bom/openhab-core/pom.xml +++ b/bom/openhab-core/pom.xml @@ -148,6 +148,12 @@ ${project.version} compile + + org.openhab.core.bundles + org.openhab.core.io.transport.sbus + ${project.version} + compile + org.openhab.core.bundles org.openhab.core.io.transport.serial diff --git a/bundles/org.openhab.core.io.transport.sbus/.classpath b/bundles/org.openhab.core.io.transport.sbus/.classpath new file mode 100644 index 00000000000..c6bb293e0c1 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/.classpath @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bundles/org.openhab.core.io.transport.sbus/.project b/bundles/org.openhab.core.io.transport.sbus/.project new file mode 100644 index 00000000000..fae9d582dd9 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/.project @@ -0,0 +1,23 @@ + + + org.openhab.core.io.transport.sbus + + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.eclipse.m2e.core.maven2Builder + + + + + + org.eclipse.jdt.core.javanature + org.eclipse.m2e.core.maven2Nature + + diff --git a/bundles/org.openhab.core.io.transport.sbus/NOTICE b/bundles/org.openhab.core.io.transport.sbus/NOTICE new file mode 100644 index 00000000000..f455c9ebd49 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/NOTICE @@ -0,0 +1,20 @@ +This content is produced and maintained by the openHAB project. + +* Project home: https://www.openhab.org + +== Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the Eclipse Public License 2.0 which is available at +https://www.eclipse.org/legal/epl-2.0/. + +== Source Code + +https://github.com/openhab/openhab-core + +== Third-party Content + +jsoup +* License: MIT License +* Project: https://jsoup.org/ +* Source: https://github.com/jhy/jsoup diff --git a/bundles/org.openhab.core.io.transport.sbus/bnd.bnd b/bundles/org.openhab.core.io.transport.sbus/bnd.bnd new file mode 100644 index 00000000000..5c1d6b52243 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/bnd.bnd @@ -0,0 +1,10 @@ +Bundle-SymbolicName: ${project.artifactId} +Automatic-Module-Name: ${def;bsn} +Import-Package: \ + org.eclipse.jdt.annotation.*;resolution:=optional,\ + org.openhab.*;version=!,\ + !net.sf.cglib.proxy,\ + gnu.io;version="[3.12,6)",\ + * +-sources: false +-contract: * diff --git a/bundles/org.openhab.core.io.transport.sbus/pom.xml b/bundles/org.openhab.core.io.transport.sbus/pom.xml new file mode 100644 index 00000000000..480607bf94e --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/pom.xml @@ -0,0 +1,71 @@ + + + + 4.0.0 + + + org.openhab.core.bundles + org.openhab.core.reactor.bundles + 4.2.0-SNAPSHOT + + + org.openhab.core.io.transport.sbus + + openHAB Core :: Bundles :: S-Bus Transport + + + + org.openhab.core.bundles + org.openhab.core.config.core + ${project.version} + + + org.openhab.core.bundles + org.openhab.core.test + ${project.version} + test + + + org.apache.commons + commons-pool2 + 2.8.1 + compile + + + ro.ciprianpascu + j2sbus + 1.3.5.OH + compile + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.1.1 + + + embed-dependencies + + unpack-dependencies + + + runtime + jar + commons-pool2,org.openhab.core.bundles + ${project.build.directory}/classes + true + true + true + jar + + + + + + + + diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/AsyncModbusFailure.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/AsyncModbusFailure.java new file mode 100644 index 00000000000..695c5988e5b --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/AsyncModbusFailure.java @@ -0,0 +1,65 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus; + +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Encapsulates result of modbus read operations + * + * @author Nagy Attila Gabor - Initial contribution + */ +@NonNullByDefault +public class AsyncModbusFailure { + private final R request; + + private final Exception cause; + + public AsyncModbusFailure(R request, Exception cause) { + Objects.requireNonNull(request, "Request must not be null!"); + Objects.requireNonNull(cause, "Cause must not be null!"); + this.request = request; + this.cause = cause; + } + + /** + * Get request matching this response + * + * @return request object + */ + public R getRequest() { + return request; + } + + /** + * Get cause of error + * + * @return exception representing error + */ + public Exception getCause() { + return cause; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder("AsyncModbusReadResult("); + builder.append("request = "); + builder.append(request); + builder.append(", error = "); + builder.append(cause); + builder.append(")"); + return builder.toString(); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/AsyncModbusReadResult.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/AsyncModbusReadResult.java new file mode 100644 index 00000000000..9540913f108 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/AsyncModbusReadResult.java @@ -0,0 +1,93 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus; + +import java.util.Objects; +import java.util.Optional; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Encapsulates result of modbus read operations + * + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public class AsyncModbusReadResult { + + private final ModbusReadRequestBlueprint request; + + private final Optional bits; + + private final Optional registers; + + public AsyncModbusReadResult(ModbusReadRequestBlueprint request, ModbusRegisterArray registers) { + Objects.requireNonNull(request, "Request must not be null!"); + Objects.requireNonNull(registers, "Registers must not be null!"); + this.request = request; + this.registers = Optional.of(registers); + this.bits = Optional.empty(); + } + + public AsyncModbusReadResult(ModbusReadRequestBlueprint request, BitArray bits) { + Objects.requireNonNull(request, "Request must not be null!"); + Objects.requireNonNull(bits, "Bits must not be null!"); + this.request = request; + this.registers = Optional.empty(); + this.bits = Optional.of(bits); + } + + /** + * Get request matching this response + * + * @return request object + */ + public ModbusReadRequestBlueprint getRequest() { + return request; + } + + /** + * Get "coil" or "discrete input" bit data in the case of no errors + * + * @return bit data + */ + public Optional getBits() { + return bits; + } + + /** + * Get "input register" or "holding register" data in the case of no errors + * + * @return register data + */ + public Optional getRegisters() { + return registers; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder("AsyncModbusReadResult("); + builder.append("request = "); + builder.append(request); + bits.ifPresent(bits -> { + builder.append(", bits = "); + builder.append(bits); + }); + registers.ifPresent(registers -> { + builder.append(", registers = "); + builder.append(registers); + }); + builder.append(")"); + return builder.toString(); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/AsyncModbusWriteResult.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/AsyncModbusWriteResult.java new file mode 100644 index 00000000000..08e3763b16b --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/AsyncModbusWriteResult.java @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus; + +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Encapsulates result of modbus write operations + * + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public class AsyncModbusWriteResult { + + private final ModbusWriteRequestBlueprint request; + + private final ModbusResponse response; + + public AsyncModbusWriteResult(ModbusWriteRequestBlueprint request, ModbusResponse response) { + Objects.requireNonNull(request, "Request must not be null!"); + Objects.requireNonNull(response, "Response must not be null!"); + this.request = request; + this.response = response; + } + + /** + * Get request matching this response + * + * @return request object + */ + public ModbusWriteRequestBlueprint getRequest() { + return request; + } + + /** + * Get response + * + * @return response + */ + public ModbusResponse getResponse() { + return response; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder("AsyncModbusWriteResult("); + builder.append("request = "); + builder.append(request); + builder.append(", response = "); + builder.append(response); + builder.append(")"); + return builder.toString(); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/BitArray.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/BitArray.java new file mode 100644 index 00000000000..79b65ac668b --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/BitArray.java @@ -0,0 +1,141 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus; + +import java.util.BitSet; +import java.util.Iterator; +import java.util.stream.IntStream; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Class that implements a collection for + * bits + * + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public class BitArray implements Iterable { + + private final BitSet wrapped; + private final int length; + + public BitArray(int nbits) { + this(new BitSet(nbits), nbits); + } + + public BitArray(boolean... bits) { + this(bitSetFromBooleans(bits), bits.length); + } + + public BitArray(BitSet wrapped, int length) { + this.wrapped = wrapped; + this.length = length; + } + + private static BitSet bitSetFromBooleans(boolean... bits) { + BitSet bitSet = new BitSet(bits.length); + for (int i = 0; i < bits.length; i++) { + bitSet.set(i, bits[i]); + } + + return bitSet; + } + + @SuppressWarnings("PMD.CompareObjectsWithEquals") + private boolean sizeAndValuesEquals(@Nullable Object obj) { + if (obj == null) { + return false; + } + if (obj == this) { + return true; + } + if (!(obj instanceof BitArray)) { + return false; + } + BitArray other = (BitArray) obj; + if (this.size() != other.size()) { + return false; + } + for (int i = 0; i < this.size(); i++) { + if (this.getBit(i) != other.getBit(i)) { + return false; + } + } + return true; + } + + /** + * Returns the state of the bit at the given index + * + * Index 0 matches LSB (rightmost) bit + *

+ * + * @param index the index of the bit to be returned. + * @return true if the bit at the specified index is set, + * false otherwise. + * @throws IndexOutOfBoundsException if the index is out of bounds. + */ + public boolean getBit(int index) { + if (index >= size()) { + throw new IndexOutOfBoundsException(); + } + return this.wrapped.get(index); + } + + public void setBit(int index, boolean value) { + if (value) { + this.wrapped.set(index); + } else { + this.wrapped.clear(index); + } + } + + /** + * Get number of bits stored in this instance + * + * @return + */ + public int size() { + return length; + } + + @Override + public String toString() { + return "BitArray(bits=" + (length == 0 ? "" : toBinaryString()) + ")"; + } + + @Override + public Iterator iterator() { + return IntStream.range(0, size()).mapToObj(this::getBit).iterator(); + } + + @Override + public boolean equals(@Nullable Object obj) { + return sizeAndValuesEquals(obj); + } + + /** + * Get data as binary string + * + * For example, 0010 + * + * @return string representing the data + */ + public String toBinaryString() { + final StringBuilder buffer = new StringBuilder(size()); + IntStream.range(0, size()).mapToObj(i -> getBit(i) ? '1' : '0').forEach(buffer::append); + return buffer.toString(); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusBitUtilities.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusBitUtilities.java new file mode 100644 index 00000000000..6dc1843cdde --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusBitUtilities.java @@ -0,0 +1,761 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.charset.Charset; +import java.util.Optional; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.io.transport.sbus.ModbusConstants.ValueType; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.OpenClosedType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.unit.Units; +import org.openhab.core.types.Command; + +/** + * Utilities for working with binary data. + * + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public class ModbusBitUtilities { + + /** + * Read data from registers and convert the result to DecimalType + * Interpretation of index goes as follows depending on type + * + * BIT: + * - a single bit is read from the registers + * - indices between 0...15 (inclusive) represent bits of the first register + * - indices between 16...31 (inclusive) represent bits of the second register, etc. + * - index 0 refers to the least significant bit of the first register + * - index 1 refers to the second least significant bit of the first register, etc. + * INT8: + * - a byte (8 bits) from the registers is interpreted as signed integer + * - index 0 refers to low byte of the first register, 1 high byte of first register + * - index 2 refers to low byte of the second register, 3 high byte of second register, etc. + * - it is assumed that each high and low byte is encoded in most significant bit first order + * UINT8: + * - same as INT8 except value is interpreted as unsigned integer + * INT16: + * - register with index (counting from zero) is interpreted as 16 bit signed integer. + * - it is assumed that each register is encoded in most significant bit first order + * UINT16: + * - same as INT16 except value is interpreted as unsigned integer + * INT32: + * - registers (index) and (index + 1) are interpreted as signed 32bit integer. + * - it assumed that the first register contains the most significant 16 bits + * - it is assumed that each register is encoded in most significant bit first order + * INT32_SWAP: + * - Same as INT32 but registers swapped + * UINT32: + * - same as INT32 except value is interpreted as unsigned integer + * UINT32_SWAP: + * - same as INT32_SWAP except value is interpreted as unsigned integer + * FLOAT32: + * - registers (index) and (index + 1) are interpreted as signed 32bit floating point number. + * - it assumed that the first register contains the most significant 16 bits + * - it is assumed that each register is encoded in most significant bit first order + * - floating point NaN and infinity will return as empty optional + * FLOAT32_SWAP: + * - Same as FLOAT32 but registers swapped + * INT64: + * - registers (index), (index + 1), (index + 2), (index + 3) are interpreted as signed 64bit integer. + * - it assumed that the first register contains the most significant 16 bits + * - it is assumed that each register is encoded in most significant bit first order + * INT64_SWAP: + * - same as INT64 but registers swapped, that is, registers (index + 3), (index + 2), (index + 1), (index + 0) are + * interpreted as signed 64bit integer + * UINT64: + * - same as INT64 except value is interpreted as unsigned integer + * UINT64_SWAP: + * - same as INT64_SWAP except value is interpreted as unsigned integer + * + * @param registers list of registers, each register represent 16bit of data + * @param index zero based item index. Interpretation of this depends on type, see examples above. + * With type larger or equal to 16 bits, the index tells the register index to start reading + * from. + * With type less than 16 bits, the index tells the N'th item to read from the registers. + * @param type item type, e.g. unsigned 16bit integer (ModbusBindingProvider.ValueType.UINT16) + * @return number representation queried value, DecimalType. Empty optional is returned + * with NaN and infinity floating point values + * @throws IllegalStateException in cases where implementation is lacking for the type. This can be considered a + * bug + * @throws IllegalArgumentException when index is out of bounds of registers + * + */ + public static Optional extractStateFromRegisters(ModbusRegisterArray registers, int index, + ModbusConstants.ValueType type) { + byte[] bytes = registers.getBytes(); + switch (type) { + case BIT: + return Optional.of(new DecimalType(extractBit(bytes, index))); + case INT8: { + int registerIndex = index / 2; + boolean hiByte = index % 2 == 1; + return Optional.of(new DecimalType(extractSInt8(bytes, registerIndex, hiByte))); + } + case UINT8: { + int registerIndex = index / 2; + boolean hiByte = index % 2 == 1; + return Optional.of(new DecimalType(extractUInt8(bytes, registerIndex, hiByte))); + } + case INT16: + return Optional.of(new DecimalType(extractSInt16(bytes, index * 2))); + case UINT16: + return Optional.of(new DecimalType(extractUInt16(bytes, index * 2))); + case INT32: + return Optional.of(new DecimalType(extractSInt32(bytes, index * 2))); + case UINT32: + return Optional.of(new DecimalType(extractUInt32(bytes, index * 2))); + case FLOAT32: + try { + return Optional.of(new DecimalType(extractFloat32(bytes, index * 2))); + } catch (NumberFormatException e) { + // floating point NaN or infinity encountered + return Optional.empty(); + } + case INT64: + return Optional.of(new DecimalType(extractSInt64(bytes, index * 2))); + case UINT64: + return Optional.of(new DecimalType(new BigDecimal(extractUInt64(bytes, index * 2)))); + case INT32_SWAP: + return Optional.of(new DecimalType(extractSInt32Swap(bytes, index * 2))); + case UINT32_SWAP: + return Optional.of(new DecimalType(extractUInt32Swap(bytes, index * 2))); + case FLOAT32_SWAP: + try { + return Optional.of(new DecimalType(extractFloat32Swap(bytes, index * 2))); + } catch (NumberFormatException e) { + // floating point NaN or infinity encountered + return Optional.empty(); + } + case INT64_SWAP: + return Optional.of(new DecimalType(extractSInt64Swap(bytes, index * 2))); + case UINT64_SWAP: + return Optional.of(new DecimalType(new BigDecimal(extractUInt64Swap(bytes, index * 2)))); + default: + throw new IllegalStateException(type.getConfigValue()); + } + } + + private static void assertIndexAndType(byte[] bytes, int index, ValueType type) { + int typeBits = type.getBits(); + // for 8-bit types and larger, index specifies the index of the byte. For bits, index specifies the index of the + // bit (of the whole data) + int indexPositionAsBitIndex = Math.min(type.getBits(), 8) * index; + int endBitIndex = indexPositionAsBitIndex + typeBits - 1; + int lastValidIndex = bytes.length * 8 - 1; + if (endBitIndex > lastValidIndex || index < 0) { + throw new IllegalArgumentException( + String.format("Index=%d with type=%s is out-of-bounds given registers of size %d ", index, type, + bytes.length / 2)); + } + } + + /** + * Extract single bit from registers represented by sequence of bytes + * + * - indices between 0...15 (inclusive) represent bits of the first register + * - indices between 16...31 (inclusive) represent bits of the second register, etc. + * - index 0 refers to the least significant bit of the first register + * - index 1 refers to the second least significant bit of the first register, etc. + * + * @param bytes registers represented by sequence of bytes + * @param index index of bit + * @return 0 when bit is set, 1 otherwise + * @throws IllegalArgumentException when index is out of bounds + */ + public static int extractBit(byte[] bytes, int index) { + assertIndexAndType(bytes, index, ValueType.BIT); + int registerIndex = index / 16; + int bitIndexWithinRegister = index % 16; + return extractBit(bytes, registerIndex, bitIndexWithinRegister); + } + + /** + * Extract single bit from registers represented by sequence of bytes + * + * bitIndexWithinRegister between 0...15 (inclusive) represent bits of the first register, where 0 refers to the + * least significant bit of the register, index 1 refers to the second least significant bit of the register, etc. + * + * @param bytes registers represented by sequence of bytes + * @param registerIndex index of register. First register has index of 0. + * @param bitIndexWithinRegister bit index within the register + * @return 0 when bit is set, 1 otherwise + * @throws IllegalArgumentException when registerIndex and/or bitIndexWithinRegister is out of bounds + */ + public static int extractBit(byte[] bytes, int registerIndex, int bitIndexWithinRegister) { + if (bitIndexWithinRegister < 0 || bitIndexWithinRegister > 15) { + throw new IllegalArgumentException( + String.format("bitIndexWithinRegister=%d is out-of-bounds (max 15)", bitIndexWithinRegister)); + } else if (registerIndex < 0) { + throw new IllegalArgumentException( + String.format("registerIndex=%d is out-of-bounds", bitIndexWithinRegister)); + } + boolean hiByte = bitIndexWithinRegister >= 8; + int indexWithinByte = bitIndexWithinRegister % 8; + int byteIndex = 2 * registerIndex + (hiByte ? 0 : 1); + if (byteIndex >= bytes.length) { + throw new IllegalArgumentException(String.format( + "registerIndex=%d, bitIndexWithinRegister=%d is out-of-bounds with registers of size %d", + registerIndex, bitIndexWithinRegister, bytes.length / 2)); + } + return ((bytes[byteIndex] >>> indexWithinByte) & 1); + } + + /** + * Extract signed 8-bit integer (byte) from registers represented by sequence of bytes + * + * @param bytes registers represented by sequence of bytes + * @param registerIndex index of register. First register has index of 0. + * @param hiByte whether to extract hi byte or lo byte + * @return 0 when bit is set, 1 otherwise + * @throws IllegalArgumentException when index is out of bounds + */ + public static byte extractSInt8(byte[] bytes, int registerIndex, boolean hiByte) { + int byteIndex = 2 * registerIndex + (hiByte ? 0 : 1); + return extractSInt8(bytes, byteIndex); + } + + /** + * Extract signed 8-bit integer (byte) from registers represented by sequence of bytes + * + * - index 0 refers to low byte of the first register, 1 high byte of first register + * - index 2 refers to low byte of the second register, 3 high byte of second register, etc. + * - it is assumed that each high and low byte is encoded in most significant bit first order + * + * @param bytes registers represented by sequence of bytes + * @param index index of the byte in registers + * @return 0 when bit is set, 1 otherwise + * @throws IllegalArgumentException when index is out of bounds + */ + public static byte extractSInt8(byte[] bytes, int index) { + assertIndexAndType(bytes, index, ValueType.INT8); + return bytes[index]; + } + + /** + * Extract unsigned 8-bit integer (byte) from registers represented by sequence of bytes + * + * @param bytes registers represented by sequence of bytes + * @param registerIndex index of register. First register has index of 0. + * @param hiByte whether to extract hi byte or lo byte + * @return 0 when bit is set, 1 otherwise + * @throws IllegalArgumentException when registerIndex is out of bounds + */ + public static short extractUInt8(byte[] bytes, int registerIndex, boolean hiByte) { + int byteIndex = 2 * registerIndex + (hiByte ? 0 : 1); + return extractUInt8(bytes, byteIndex); + } + + /** + * Extract unsigned 8-bit integer (byte) from registers represented by sequence of bytes + * + * - index 0 refers to low byte of the first register, 1 high byte of first register + * - index 2 refers to low byte of the second register, 3 high byte of second register, etc. + * - it is assumed that each high and low byte is encoded in most significant bit first order + * + * @param bytes registers represented by sequence of bytes + * @param index index of the byte in registers + * @return 0 when bit is set, 1 otherwise + * @throws IllegalArgumentException when index is out of bounds + */ + public static short extractUInt8(byte[] bytes, int index) { + assertIndexAndType(bytes, index, ValueType.UINT8); + int signed = extractSInt8(bytes, index); + short unsigned = (short) (signed & 0xff); + assert unsigned >= 0; + return unsigned; + } + + /** + * Extract signed 16-bit integer (short) from registers represented by sequence of bytes + * + * It is assumed that each register is encoded in most significant bit first order + * + * @param bytes registers represented by sequence of bytes + * @param index index of register. First register has index of 0. + * @return register with index interpreted as 16 bit signed integer + * @throws IllegalArgumentException when index is out of bounds + */ + public static short extractSInt16(byte[] bytes, int index) { + assertIndexAndType(bytes, index, ValueType.INT16); + int hi = (bytes[index] & 0xff); + int lo = (bytes[index + 1] & 0xff); + return (short) ((hi << 8) | lo); + } + + /** + * Extract unsigned 16-bit integer from registers represented by sequence of bytes + * + * It is assumed that each register is encoded in most significant bit first order + * + * @param bytes registers represented by sequence of bytes + * @param index index of register. First register has index of 0. + * @return register with index interpreted as 16 bit unsigned integer + * @throws IllegalArgumentException when index is out of bounds + */ + public static int extractUInt16(byte[] bytes, int index) { + assertIndexAndType(bytes, index, ValueType.UINT16); + int signed = extractSInt16(bytes, index); + int unsigned = signed & 0xffff; + assert unsigned >= 0; + return unsigned; + } + + /** + * Extract signed 32-bit integer from registers represented by sequence of bytes + * + * It is assumed that each register is encoded in most significant bit first order + * + * @param bytes registers represented by sequence of bytes + * @param index index of first register. First register has index of 0. + * @return registers (index) and (index+1) interpreted as 32 bit signed integer + * @throws IllegalArgumentException when index is out of bounds + */ + public static int extractSInt32(byte[] bytes, int index) { + assertIndexAndType(bytes, index, ValueType.INT32); + int hi1 = bytes[index + 0] & 0xff; + int lo1 = bytes[index + 1] & 0xff; + int hi2 = bytes[index + 2] & 0xff; + int lo2 = bytes[index + 3] & 0xff; + return (hi1 << 24) | (lo1 << 16) | (hi2 << 8) | lo2; + } + + /** + * Extract unsigned 32-bit integer from registers represented by sequence of bytes + * + * It is assumed that each register is encoded in most significant bit first order + * + * @param bytes registers represented by sequence of bytes + * @param index index of first register. First register has index of 0. + * @return registers (index) and (index+1) interpreted as 32 bit unsigned integer + * @throws IllegalArgumentException when index is out of bounds + */ + public static long extractUInt32(byte[] bytes, int index) { + assertIndexAndType(bytes, index, ValueType.UINT32); + long signed = extractSInt32(bytes, index); + long unsigned = signed & 0xffff_ffffL; + assert unsigned >= 0; + return unsigned; + } + + /** + * Extract signed 32-bit integer from registers represented by sequence of bytes + * + * It is assumed that each register is encoded in most significant bit first order. + * + * This is identical with extractSInt32, but with registers swapped. + * + * @param bytes registers represented by sequence of bytes + * @param index index of first register. First register has index of 0. + * @return registers (index+1), (index) interpreted as 32 bit signed integer + * @throws IllegalArgumentException when index is out of bounds + */ + public static int extractSInt32Swap(byte[] bytes, int index) { + assertIndexAndType(bytes, index, ValueType.INT32_SWAP); + // swapped order of registers, high 16 bits *follow* low 16 bits + int hi1 = bytes[index + 2] & 0xff; + int lo1 = bytes[index + 3] & 0xff; + int hi2 = bytes[index + 0] & 0xff; + int lo2 = bytes[index + 1] & 0xff; + return (hi1 << 24) | (lo1 << 16) | (hi2 << 8) | lo2; + } + + /** + * Extract unsigned 32-bit integer from registers represented by sequence of bytes + * + * It is assumed that each register is encoded in most significant bit first order. + * + * This is identical with extractUInt32, but with registers swapped. + * + * @param bytes registers represented by sequence of bytes + * @param index index of first register. First register has index of 0. + * @return registers (index+1), (index) interpreted as 32 bit unsigned integer + * @throws IllegalArgumentException when index is out of bounds + */ + public static long extractUInt32Swap(byte[] bytes, int index) { + assertIndexAndType(bytes, index, ValueType.UINT32_SWAP); + long signed = extractSInt32Swap(bytes, index); + long unsigned = signed & 0xffff_ffffL; + assert unsigned >= 0; + return unsigned; + } + + /** + * Extract signed 64-bit integer from registers represented by sequence of bytes + * + * It is assumed that each register is encoded in most significant bit first order. + * + * @param bytes registers represented by sequence of bytes + * @param index index of first register. First register has index of 0. + * @return registers (index), (index+1), (index+2), (index+3) interpreted as 64 bit signed integer + * @throws IllegalArgumentException when index is out of bounds + */ + public static long extractSInt64(byte[] bytes, int index) { + assertIndexAndType(bytes, index, ValueType.INT64); + byte hi1 = (byte) (bytes[index + 0] & 0xff); + byte lo1 = (byte) (bytes[index + 1] & 0xff); + byte hi2 = (byte) (bytes[index + 2] & 0xff); + byte lo2 = (byte) (bytes[index + 3] & 0xff); + byte hi3 = (byte) (bytes[index + 4] & 0xff); + byte lo3 = (byte) (bytes[index + 5] & 0xff); + byte hi4 = (byte) (bytes[index + 6] & 0xff); + byte lo4 = (byte) (bytes[index + 7] & 0xff); + return new BigInteger(new byte[] { hi1, lo1, hi2, lo2, hi3, lo3, hi4, lo4 }).longValue(); + } + + /** + * Extract unsigned 64-bit integer from registers represented by sequence of bytes + * + * It is assumed that each register is encoded in most significant bit first order. + * + * @param bytes registers represented by sequence of bytes + * @param index index of first register. First register has index of 0. + * @return registers (index), (index+1), (index+2), (index+3) interpreted as 64 bit unsigned integer + * @throws IllegalArgumentException when index is out of bounds + */ + public static BigInteger extractUInt64(byte[] bytes, int index) { + assertIndexAndType(bytes, index, ValueType.UINT64); + byte hi1 = (byte) (bytes[index + 0] & 0xff); + byte lo1 = (byte) (bytes[index + 1] & 0xff); + byte hi2 = (byte) (bytes[index + 2] & 0xff); + byte lo2 = (byte) (bytes[index + 3] & 0xff); + byte hi3 = (byte) (bytes[index + 4] & 0xff); + byte lo3 = (byte) (bytes[index + 5] & 0xff); + byte hi4 = (byte) (bytes[index + 6] & 0xff); + byte lo4 = (byte) (bytes[index + 7] & 0xff); + return new BigInteger(1, new byte[] { hi1, lo1, hi2, lo2, hi3, lo3, hi4, lo4 }); + } + + /** + * Extract signed 64-bit integer from registers represented by sequence of bytes + * + * It is assumed that each register is encoded in most significant bit first order. + * + * This is identical with extractInt64, but with registers swapped (registers with higher index before lower index). + * + * @param bytes registers represented by sequence of bytes + * @param index index of first register. First register has index of 0. + * @return registers (index+3), (index+2), (index+1), (index) interpreted as 64 bit signed integer + * @throws IllegalArgumentException when index is out of bounds + */ + public static long extractSInt64Swap(byte[] bytes, int index) { + assertIndexAndType(bytes, index, ValueType.INT64_SWAP); + // Swapped order of registers + byte hi1 = (byte) (bytes[index + 6] & 0xff); + byte lo1 = (byte) (bytes[index + 7] & 0xff); + byte hi2 = (byte) (bytes[index + 4] & 0xff); + byte lo2 = (byte) (bytes[index + 5] & 0xff); + byte hi3 = (byte) (bytes[index + 2] & 0xff); + byte lo3 = (byte) (bytes[index + 3] & 0xff); + byte hi4 = (byte) (bytes[index + 0] & 0xff); + byte lo4 = (byte) (bytes[index + 1] & 0xff); + return new BigInteger(new byte[] { hi1, lo1, hi2, lo2, hi3, lo3, hi4, lo4 }).longValue(); + } + + /** + * Extract unsigned 64-bit integer from registers represented by sequence of bytes + * + * It is assumed that each register is encoded in most significant bit first order. + * + * This is identical with extractUInt64, but with registers swapped (registers with higher index before lower + * index). + * + * @param bytes registers represented by sequence of bytes + * @param index index of first register. First register has index of 0. + * @return registers (index+3), (index+2), (index+1), (index) interpreted as 64 bit unsigned integer + * @throws IllegalArgumentException when index is out of bounds + */ + public static BigInteger extractUInt64Swap(byte[] bytes, int index) { + assertIndexAndType(bytes, index, ValueType.UINT64_SWAP); + // Swapped order of registers + byte hi1 = (byte) (bytes[index + 6] & 0xff); + byte lo1 = (byte) (bytes[index + 7] & 0xff); + byte hi2 = (byte) (bytes[index + 4] & 0xff); + byte lo2 = (byte) (bytes[index + 5] & 0xff); + byte hi3 = (byte) (bytes[index + 2] & 0xff); + byte lo3 = (byte) (bytes[index + 3] & 0xff); + byte hi4 = (byte) (bytes[index + 0] & 0xff); + byte lo4 = (byte) (bytes[index + 1] & 0xff); + return new BigInteger(1, new byte[] { hi1, lo1, hi2, lo2, hi3, lo3, hi4, lo4 }); + } + + /** + * Extract single-precision 32-bit IEEE 754 floating point from registers represented by sequence of bytes + * + * It is assumed that each register is encoded in most significant bit first order. + * + * Note that this method can return floating point NaN and floating point infinity. + * + * @param bytes registers represented by sequence of bytes + * @param index index of first register. First register has index of 0. + * @return registers (index), (index+1), (index+2), (index+3) interpreted as single-precision 32-bit IEEE 754 + * floating point + * @throws IllegalArgumentException when index is out of bounds + */ + public static float extractFloat32(byte[] bytes, int index) { + assertIndexAndType(bytes, index, ValueType.FLOAT32); + int hi1 = bytes[index + 0] & 0xff; + int lo1 = bytes[index + 1] & 0xff; + int hi2 = bytes[index + 2] & 0xff; + int lo2 = bytes[index + 3] & 0xff; + int bits32 = (hi1 << 24) | (lo1 << 16) | (hi2 << 8) | lo2; + return Float.intBitsToFloat(bits32); + } + + /** + * Extract single-precision 32-bit IEEE 754 floating point from registers represented by sequence of bytes + * + * It is assumed that each register is encoded in most significant bit first order. + * + * This is identical with extractFloat32, but with registers swapped (registers with higher index before lower + * index). + * + * Note that this method can return floating point NaN and floating point infinity. + * + * @param bytes registers represented by sequence of bytes + * @param index index of first register. First register has index of 0. + * @return registers (index+3), (index+2), (index+1), (index) interpreted as single-precision 32-bit IEEE 754 + * floating point + * @throws IllegalArgumentException when index is out of bounds + */ + public static float extractFloat32Swap(byte[] bytes, int index) { + assertIndexAndType(bytes, index, ValueType.FLOAT32_SWAP); + // swapped order of registers, high 16 bits *follow* low 16 bits + int hi1 = bytes[index + 2] & 0xff; + int lo1 = bytes[index + 3] & 0xff; + int hi2 = bytes[index + 0] & 0xff; + int lo2 = bytes[index + 1] & 0xff; + int bits32 = (hi1 << 24) | (lo1 << 16) | (hi2 << 8) | lo2; + return Float.intBitsToFloat(bits32); + } + + /** + * Read data from registers and convert the result to String + * Strings should start the the first byte of a register, but could + * have an odd number of characters. + * Raw byte array values are converted using the charset parameter + * and a maximum of length bytes are read. However reading stops at the first + * NUL byte encountered. + * + * Registers are read in big-endian order, i.e. two registers consisting 4 bytes (ab, cd) are parsed as sequence of + * bytes (a,b,c,d). + * + * @param registers list of registers, each register represent 16bit of data + * @param registerIndex zero based register index. Registers are handled as 16bit registers, + * this parameter defines the starting register. + * @param length maximum length of string in 8bit characters (number of bytes considered) + * @param charset the character set used to construct the string. + * @return string representation queried value + * @throws IllegalArgumentException when index is out of bounds of registers + */ + public static String extractStringFromRegisters(ModbusRegisterArray registers, int registerIndex, int length, + Charset charset) { + return extractStringFromBytes(registers.getBytes(), registerIndex * 2, length, charset); + } + + /** + * Read data from bytes and convert the result to String + * + * Raw byte array values are converted using the charset parameter + * and a maximum of length bytes are read. However reading stops at the first + * NUL byte encountered. + * + * @param bytes bytes representing the registers + * @param byteIndex zero based byte index + * @param length maximum length of string in 8bit characters (number of bytes considered) + * @param charset the character set used to construct the string. + * @return string representation queried value + * @throws IllegalArgumentException when index is out of bounds of registers + */ + public static String extractStringFromBytes(byte[] bytes, int byteIndex, int length, Charset charset) { + if (byteIndex + length > bytes.length) { + throw new IllegalArgumentException( + String.format("byteIndex=%d with length=%d is out-of-bounds given registers of size %d", byteIndex, + length, bytes.length)); + } + if (byteIndex < 0) { + throw new IllegalArgumentException("Negative index values are not supported"); + } + if (length < 0) { + throw new IllegalArgumentException("Negative string length is not supported"); + } + + int effectiveLength = length; + + // Find first zero byte in registers and call reduce length such that we stop before it + for (int i = 0; i < length; i++) { + if (bytes[byteIndex + i] == '\0') { + effectiveLength = i; + break; + } + } + + return new String(bytes, byteIndex, effectiveLength, charset); + } + + /** + * Convert command to array of registers using a specific value type + * + * @param command command to be converted. Either OnOffType, OpenClosedType, DecimalType or QuantityType that can be + * converted to dimensionless unit. + * @param type value type to use in conversion + * @return array of registers + * @throws IllegalArgumentException in cases where implementation is lacking for the type. This is thrown with 1-bit + * and 8-bit value types. Also raised with unsupported command types + */ + public static ModbusRegisterArray commandToRegisters(Command command, ModbusConstants.ValueType type) { + Number numericCommand; + if (command instanceof OnOffType || command instanceof OpenClosedType) { + numericCommand = translateCommand2Boolean(command).get() ? new DecimalType(BigDecimal.ONE) + : DecimalType.ZERO; + } else if (command instanceof DecimalType decimalType) { + numericCommand = decimalType; + } else if (command instanceof QuantityType quantityType) { + QuantityType qtCommand = quantityType.toUnit(Units.ONE); + if (qtCommand == null) { + throw new IllegalArgumentException( + String.format("Command '%s' of class '%s' cannot be converted to bare number.", command, + command.getClass().getName())); + } + numericCommand = qtCommand; + } else { + throw new IllegalArgumentException(String.format( + "Command '%s' of class '%s' cannot be converted to registers. Please use OnOffType, OpenClosedType, DecimalType or dimensionless QuantityType commands.", + command, command.getClass().getName())); + } + if (type.getBits() != 16 && type.getBits() != 32 && type.getBits() != 64) { + throw new IllegalArgumentException(String.format( + "Illegal type=%s (bits=%d). Only 16bit and 32bit types are supported", type, type.getBits())); + } + switch (type) { + case INT16: + case UINT16: { + short shortValue = numericCommand.shortValue(); + // big endian byte ordering + byte hi = (byte) (shortValue >> 8); + byte lo = (byte) shortValue; + return new ModbusRegisterArray(new byte[] { hi, lo }); + } + case INT32: + case UINT32: { + int intValue = numericCommand.intValue(); + // big endian byte ordering + byte hi1 = (byte) (intValue >> 24); + byte lo1 = (byte) (intValue >> 16); + byte hi2 = (byte) (intValue >> 8); + byte lo2 = (byte) intValue; + return new ModbusRegisterArray(new byte[] { hi1, lo1, hi2, lo2 }); + } + case INT32_SWAP: + case UINT32_SWAP: { + int intValue = numericCommand.intValue(); + // big endian byte ordering + byte hi1 = (byte) (intValue >> 24); + byte lo1 = (byte) (intValue >> 16); + byte hi2 = (byte) (intValue >> 8); + byte lo2 = (byte) intValue; + // Swapped order of registers + return new ModbusRegisterArray(new byte[] { hi2, lo2, hi1, lo1 }); + } + case FLOAT32: { + float floatValue = numericCommand.floatValue(); + int intBits = Float.floatToIntBits(floatValue); + // big endian byte ordering + byte hi1 = (byte) (intBits >> 24); + byte lo1 = (byte) (intBits >> 16); + byte hi2 = (byte) (intBits >> 8); + byte lo2 = (byte) intBits; + return new ModbusRegisterArray(new byte[] { hi1, lo1, hi2, lo2 }); + } + case FLOAT32_SWAP: { + float floatValue = numericCommand.floatValue(); + int intBits = Float.floatToIntBits(floatValue); + // big endian byte ordering + byte hi1 = (byte) (intBits >> 24); + byte lo1 = (byte) (intBits >> 16); + byte hi2 = (byte) (intBits >> 8); + byte lo2 = (byte) intBits; + // Swapped order of registers + return new ModbusRegisterArray(new byte[] { hi2, lo2, hi1, lo1 }); + } + case INT64: + case UINT64: { + long longValue = numericCommand.longValue(); + // big endian byte ordering + byte hi1 = (byte) (longValue >> 56); + byte lo1 = (byte) (longValue >> 48); + byte hi2 = (byte) (longValue >> 40); + byte lo2 = (byte) (longValue >> 32); + byte hi3 = (byte) (longValue >> 24); + byte lo3 = (byte) (longValue >> 16); + byte hi4 = (byte) (longValue >> 8); + byte lo4 = (byte) longValue; + return new ModbusRegisterArray(new byte[] { hi1, lo1, hi2, lo2, hi3, lo3, hi4, lo4 }); + } + case INT64_SWAP: + case UINT64_SWAP: { + long longValue = numericCommand.longValue(); + // big endian byte ordering + byte hi1 = (byte) (longValue >> 56); + byte lo1 = (byte) (longValue >> 48); + byte hi2 = (byte) (longValue >> 40); + byte lo2 = (byte) (longValue >> 32); + byte hi3 = (byte) (longValue >> 24); + byte lo3 = (byte) (longValue >> 16); + byte hi4 = (byte) (longValue >> 8); + byte lo4 = (byte) longValue; + // Swapped order of registers + return new ModbusRegisterArray(new byte[] { hi4, lo4, hi3, lo3, hi2, lo2, hi1, lo1 }); + } + default: + throw new IllegalArgumentException( + String.format("Illegal type=%s. Missing implementation for this type", type)); + } + } + + /** + * Converts command to a boolean + * + * true value is represented by {@link OnOffType#ON}, {@link OpenClosedType#OPEN}. + * false value is represented by {@link OnOffType#OFF}, {@link OpenClosedType#CLOSED}. + * Furthermore, {@link DecimalType} are converted to boolean true if they are unequal to zero. + * + * @param command to convert to boolean + * @return Boolean value matching the command. Empty if command cannot be converted + */ + public static Optional translateCommand2Boolean(Command command) { + if (command.equals(OnOffType.ON)) { + return Optional.of(Boolean.TRUE); + } + if (command.equals(OnOffType.OFF)) { + return Optional.of(Boolean.FALSE); + } + if (command.equals(OpenClosedType.OPEN)) { + return Optional.of(Boolean.TRUE); + } + if (command.equals(OpenClosedType.CLOSED)) { + return Optional.of(Boolean.FALSE); + } + if (command instanceof DecimalType) { + return Optional.of(!command.equals(DecimalType.ZERO)); + } + return Optional.empty(); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusCommunicationInterface.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusCommunicationInterface.java new file mode 100644 index 00000000000..fdf8613cc3c --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusCommunicationInterface.java @@ -0,0 +1,108 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus; + +import java.util.concurrent.Future; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.io.transport.sbus.endpoint.ModbusSlaveEndpoint; + +/** + * Interface for interacting with a particular modbus slave. + * + * When no further communication is expected with the slave, close the interface so that any underlying resources can be + * freed. + * + * Close unregisters all the regular polls registered with registerRegularPoll. When endpoint's last + * communication interface is closed, the connection is closed as well, no matter the what EndpointPoolConfiguration + * says. + * + * @author Sami Salonen - Initial contribution + * + */ +@NonNullByDefault +public interface ModbusCommunicationInterface extends AutoCloseable { + + /** + * Get endpoint associated with this communication interface + * + * @return modbus slave endpoint + */ + ModbusSlaveEndpoint getEndpoint(); + + /** + * Submit one-time poll task. The method returns immediately, and the execution of the poll task will happen in + * background. + * + * @param request request to send + * @param resultCallback callback to call with data + * @param failureCallback callback to call in case of failure + * @return future representing the polled task + * @throws IllegalStateException when this communication has been closed already + */ + Future submitOneTimePoll(ModbusReadRequestBlueprint request, ModbusReadCallback resultCallback, + ModbusFailureCallback failureCallback); + + /** + * Register regularly polled task. The method returns immediately, and the execution of the poll task will happen in + * the background. + * + * One can register only one regular poll task for triplet of (endpoint, request, callback). + * + * @param request request to send + * @param pollPeriodMillis poll interval, in milliseconds + * @param initialDelayMillis initial delay before starting polling, in milliseconds + * @param resultCallback callback to call with data + * @param failureCallback callback to call in case of failure + * @return poll task representing the regular poll + * @throws IllegalStateException when this communication has been closed already + */ + PollTask registerRegularPoll(ModbusReadRequestBlueprint request, long pollPeriodMillis, long initialDelayMillis, + ModbusReadCallback resultCallback, ModbusFailureCallback failureCallback); + + /** + * Unregister regularly polled task + * + * If this communication interface is closed already, the method returns immediately with false return value + * + * @param task poll task to unregister + * @return whether poll task was unregistered. Poll task is not unregistered in case of unexpected errors or + * in the case where the poll task is not registered in the first place + */ + boolean unregisterRegularPoll(PollTask task); + + /** + * Submit one-time write task. The method returns immediately, and the execution of the task will happen in + * background. + * + * @param request request to send + * @param resultCallback callback to call with response + * @param failureCallback callback to call in case of failure + * @return future representing the task + * @throws IllegalStateException when this communication has been closed already + */ + Future submitOneTimeWrite(ModbusWriteRequestBlueprint request, ModbusWriteCallback resultCallback, + ModbusFailureCallback failureCallback); + + /** + * Close this communication interface and try to free all resources associated with it + * + * Upon close, all polling tasks registered by this instance are unregistered. In addition, connections are closed + * eagerly if this was the last connection interface pointing to the endpoint. + * + * After close, the communication interface cannot be used to communicate with the device. + * + */ + @Override + void close() throws Exception; +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusConstants.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusConstants.java new file mode 100644 index 00000000000..51fbe10e636 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusConstants.java @@ -0,0 +1,143 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus; + +import java.util.stream.Stream; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Constants for Modbus transport + * + * == Regarding maximum read and write limits == + * + * Maximum number of registers that are allowed to be read. + * + * The Modbus protocol has many intepretation on maximum data size of messages. Good reference is + * here. + * + * We try to follow + * modern specification here (V1.1B3). + * See section 4.1 Protocol Specification in the specification. + * + * According to V1.1B3, maximum size for PDU is 253 bytes, making maximum ADU size 256 (RTU) or 260 (TCP). + * + * In the spec section 6, one can see maximum values for read and write counts. + * + * Note that this is not the only interpretation -- some sources limit the ADU to 256 also with TCP. + * In some cases, slaves cannot take in so much data. + * + * + * Reads are limited by response PDU size. + * Writes (FC15 and FC16) are limited by write request ADU size. + * + * + * @author Sami Salonen - Initial contribution + * + */ +@NonNullByDefault +public class ModbusConstants { + + /** + * Value types for different number types. + * + * @author Sami Salonen - Initial contribution + * + */ + public static enum ValueType { + BIT("bit", 1), + INT8("int8", 8), + UINT8("uint8", 8), + INT16("int16", 16), + UINT16("uint16", 16), + INT32("int32", 32), + UINT32("uint32", 32), + FLOAT32("float32", 32), + INT64("int64", 64), + UINT64("uint64", 64), + + INT32_SWAP("int32_swap", 32), + UINT32_SWAP("uint32_swap", 32), + FLOAT32_SWAP("float32_swap", 32), + INT64_SWAP("int64_swap", 64), + UINT64_SWAP("uint64_swap", 64); + + private final String configValue; + private final int bits; + + ValueType(String configValue, int bits) { + this.configValue = configValue; + this.bits = bits; + } + + /** + * Returns number of bits represented by this ValueType + * + * @return number of bits + */ + public int getBits() { + return bits; + } + + /** + * Returns config value to refer to this value type + * + * @return config value as string + */ + public String getConfigValue() { + return configValue; + } + + /** + * Returns config value + */ + @Override + public String toString() { + return getConfigValue(); + } + + /** + * Constructs ValueType given the config value string. + * + * @param configValueType config value that will be parsed to ValueType + * @return ValueType matching the config value + * @throws IllegalArgumentException with unknown value types + */ + public static ValueType fromConfigValue(@Nullable String configValueType) throws IllegalArgumentException { + return Stream.of(ValueType.values()).filter(v -> v.getConfigValue().equals(configValueType)).findFirst() + .orElseThrow(() -> new IllegalArgumentException("Invalid valueType " + configValueType)); + } + } + + /** + * Maximum number of coils or discrete inputs that are allowed to be read. + * Limitation by Modbus protocol V1.1B3, 6.1 definition of Read Holding registers. + */ + public static final int MAX_BITS_READ_COUNT = 2000; + /** + * Maximum number of registers that are allowed to be read. + * Limitation by Modbus protocol V1.1B3, 6.3 definition of Read Coils. + */ + public static final int MAX_REGISTERS_READ_COUNT = 125; + /** + * Maximum number of coils or discrete inputs that are allowed to be written. + * Limitation by Modbus protocol V1.1B3, 6.11 definition of Write Multiple coils. + */ + public static final int MAX_BITS_WRITE_COUNT = 1968; + /** + * Maximum number of registers that are allowed to be written. + * Limitation by Modbus protocol V1.1B3, 6.12 definition of Write Multiple registers. + */ + public static final int MAX_REGISTERS_WRITE_COUNT = 123; +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusFailureCallback.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusFailureCallback.java new file mode 100644 index 00000000000..5cfc0350ec9 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusFailureCallback.java @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Callback used to report failure in Modbus + * + * @author Nagy Attila Gabor - Initial contribution + */ +@FunctionalInterface +@NonNullByDefault +public interface ModbusFailureCallback { + /** + * Callback handling response with error + * + * @param failure details of the failure + */ + void handle(AsyncModbusFailure failure); +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusManager.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusManager.java new file mode 100644 index 00000000000..39648496e19 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusManager.java @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.io.transport.sbus.endpoint.EndpointPoolConfiguration; +import org.openhab.core.io.transport.sbus.endpoint.ModbusSlaveEndpoint; + +/** + * ModbusManager is the main interface for interacting with Modbus slaves + * + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public interface ModbusManager { + + /** + * Open communication interface to endpoint + * + * @param endpoint endpoint pointing to modbus slave + * @param configuration configuration for the endpoint. Use null to use default pool configuration + * @return Communication interface for interacting with the slave + * @throws IllegalArgumentException if there is already open communication interface with same endpoint but + * differing configuration + */ + ModbusCommunicationInterface newModbusCommunicationInterface(ModbusSlaveEndpoint endpoint, + @Nullable EndpointPoolConfiguration configuration) throws IllegalArgumentException; + + /** + * Get general configuration settings applied to a given endpoint + * + * Note that default configuration settings are returned in case the endpoint has not been configured. + * + * @param endpoint endpoint to query + * @return general connection settings of the given endpoint + */ + EndpointPoolConfiguration getEndpointPoolConfiguration(ModbusSlaveEndpoint endpoint); +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusReadCallback.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusReadCallback.java new file mode 100644 index 00000000000..e21466f62bd --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusReadCallback.java @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Interface for read callbacks + * + * @author Sami Salonen - Initial contribution + */ +@FunctionalInterface +@NonNullByDefault +public interface ModbusReadCallback extends ModbusResultCallback { + + /** + * Callback handling response data + * + * @param result result of the read operation + */ + void handle(AsyncModbusReadResult result); +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusReadFunctionCode.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusReadFunctionCode.java new file mode 100644 index 00000000000..17de1aadb9a --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusReadFunctionCode.java @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Modbus read function codes supported by this transport + * + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public enum ModbusReadFunctionCode { + READ_COILS, + READ_INPUT_DISCRETES, + READ_MULTIPLE_REGISTERS, + READ_INPUT_REGISTERS +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusReadRequestBlueprint.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusReadRequestBlueprint.java new file mode 100644 index 00000000000..87b523dd04b --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusReadRequestBlueprint.java @@ -0,0 +1,139 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus; + +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +import ro.ciprianpascu.sbus.Modbus; + +/** + * Implementation of immutable representation of modbus read request + * + * Equals and hashCode implemented keeping {@link PollTask} in mind: two instances of this class are considered the same + * if they have + * the equal parameters (same slave id, start, length, function code and maxTries). + * + * @author Sami Salonen - Initial contribution + * + */ +@NonNullByDefault +public class ModbusReadRequestBlueprint { + + private final int subnetId; + private final int slaveId; + private final ModbusReadFunctionCode functionCode; + private final int start; + private final int length; + private final int maxTries; + + public ModbusReadRequestBlueprint(int subnetId, int slaveId, ModbusReadFunctionCode functionCode, int start, + int length, int maxTries) { + this.subnetId = subnetId; + this.slaveId = slaveId; + this.functionCode = functionCode; + this.start = start; + this.length = length; + this.maxTries = maxTries; + } + + /** + * Returns the subnet identifier of this + * ModbusMessage as int.
+ * The identifier is a 1-byte non negative + * integer value valid in the range of 0-255. + *

+ * + * @return the subnet identifier as int. + */ + public int getSubnetID() { + return slaveId; + } + + /** + * Returns the unit identifier of this + * ModbusMessage as int.
+ * The identifier is a 1-byte non negative + * integer value valid in the range of 0-255. + *

+ * + * @return the unit identifier as int. + */ + public int getUnitID() { + return slaveId; + } + + public int getReference() { + return start; + } + + public ModbusReadFunctionCode getFunctionCode() { + return functionCode; + } + + public int getDataLength() { + return length; + } + + /** + * Maximum number of tries to execute the request, when request fails + * + * For example, number 1 means on try only with no re-tries. + * + * @return number of maximum tries + */ + public int getMaxTries() { + return maxTries; + } + + /** + * Returns the protocol identifier of this + * ModbusMessage as int.
+ * The identifier is a 2-byte (short) non negative + * integer value valid in the range of 0-65535. + *

+ * + * @return the protocol identifier as int. + */ + public int getProtocolID() { + return Modbus.DEFAULT_PROTOCOL_ID; + } + + @Override + public int hashCode() { + return Objects.hash(functionCode, length, maxTries, slaveId, start); + } + + @Override + public String toString() { + return "ModbusReadRequestBlueprint [slaveId=" + slaveId + ", functionCode=" + functionCode + ", start=" + start + + ", length=" + length + ", maxTries=" + maxTries + "]"; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (obj == null) { + return false; + } + if (obj == this) { + return true; + } + if (obj.getClass() != getClass()) { + return false; + } + ModbusReadRequestBlueprint rhs = (ModbusReadRequestBlueprint) obj; + return functionCode == rhs.functionCode && length == rhs.length && slaveId == rhs.slaveId && start == rhs.start; + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusRegisterArray.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusRegisterArray.java new file mode 100644 index 00000000000..8ceb16e339f --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusRegisterArray.java @@ -0,0 +1,138 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus; + +import java.util.Arrays; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.util.HexUtils; + +/** + * Immutable {@link ModbusRegisterArray} implementation + * + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public class ModbusRegisterArray { + + private final byte[] bytes; + + public ModbusRegisterArray(byte... bytes) { + if (bytes.length % 2 != 0) { + throw new IllegalArgumentException(); + } + this.bytes = Arrays.copyOf(bytes, bytes.length); + } + + /** + * Construct plain ModbusRegisterArray array from register values + * + * @param registerValues register values, each int corresponding to one register + */ + public ModbusRegisterArray(int... registerValues) { + bytes = new byte[registerValues.length * 2]; + for (int registerIndex = 0; registerIndex < registerValues.length; registerIndex++) { + int register = registerValues[registerIndex] & 0xffff; + // hi-byte + bytes[registerIndex * 2] = (byte) (register >> 8); + // lo byte + bytes[registerIndex * 2 + 1] = (byte) register; + } + } + + /** + * Get register index i as unsigned integer + * + * @param i register index + * @return register value interpreted as unsigned integer (big-endian byte ordering) + */ + public int getRegister(int i) { + int hi = bytes[i * 2] & 0xff; + int lo = bytes[i * 2 + 1] & 0xff; + return ((hi << 8) | lo) & 0xffff; + } + + /** + * Return bytes representing the registers + * + * + * Index 0: hi-byte of 1st register + * Index 1: low-byte of 1st register + * Index 3: hi-byte of 2nd register + * Index 4: low-byte of 2nd register + * ... + * + * @return set of bytes + */ + public byte[] getBytes() { + return bytes; + } + + /** + * Get number of registers stored in this instance + * + * @return + */ + public int size() { + return bytes.length / 2; + } + + @Override + public String toString() { + if (bytes.length == 0) { + return "ModbusRegisterArray()"; + } + return "ModbusRegisterArray(" + toHexString() + ')'; + } + + /** + * Get register data as a hex string + * + * For example, 04 45 00 00 + * + * @return string representing the bytes of the register array + */ + public String toHexString() { + if (size() == 0) { + return ""; + } + return HexUtils.bytesToHex(getBytes()); + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + Arrays.hashCode(bytes); + return result; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + ModbusRegisterArray other = (ModbusRegisterArray) obj; + if (!Arrays.equals(bytes, other.bytes)) { + return false; + } + return true; + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusResponse.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusResponse.java new file mode 100644 index 00000000000..b249e5d51be --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusResponse.java @@ -0,0 +1,36 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Minimal representation of a modbus response. + * + * Only function code is exposed, which allows detecting MODBUS exception codes from normal codes. + * + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public interface ModbusResponse { + + /** + * Function code of the response. + * + * Note that in case of Slave responding with Modbus exception response, the response + * function code might differ from request function code + * + * @return function code of the response + */ + int getFunctionCode(); +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusResultCallback.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusResultCallback.java new file mode 100644 index 00000000000..94e2cb51806 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusResultCallback.java @@ -0,0 +1,24 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Base interface for callbacks used in Modbus + * + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public interface ModbusResultCallback { +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusWriteCallback.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusWriteCallback.java new file mode 100644 index 00000000000..5097795c523 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusWriteCallback.java @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Interface for write callbacks + * + * @author Sami Salonen - Initial contribution + */ +@FunctionalInterface +@NonNullByDefault +public interface ModbusWriteCallback extends ModbusResultCallback { + + /** + * Callback handling response data + * + * @param result result of the write operation + */ + void handle(AsyncModbusWriteResult result); +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusWriteCoilRequestBlueprint.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusWriteCoilRequestBlueprint.java new file mode 100644 index 00000000000..9f22a5d5564 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusWriteCoilRequestBlueprint.java @@ -0,0 +1,120 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Implementation for writing coils + * + * @author Sami Salonen - Initial contribution + * + */ +@NonNullByDefault +public class ModbusWriteCoilRequestBlueprint extends ModbusWriteRequestBlueprint { + + private final int subnetId; + private final int slaveId; + private final int reference; + private final BitArray bits; + private final boolean writeMultiple; + private final int maxTries; + + /** + * Construct coil write request with single bit of data + * + * @param slaveId slave id to write to + * @param reference reference address + * @param data bit to write + * @param writeMultiple whether to use {@link ModbusWriteFunctionCode#WRITE_MULTIPLE_COILS} over + * {@link ModbusWriteFunctionCode#WRITE_COIL} + * @param maxTries maximum number of tries in case of errors, should be at least 1 + */ + public ModbusWriteCoilRequestBlueprint(int subnetId, int slaveId, int reference, boolean data, + boolean writeMultiple, int maxTries) { + this(subnetId, slaveId, reference, new BitArray(data), writeMultiple, maxTries); + } + + /** + * Construct coil write request with many bits of data + * + * @param slaveId slave id to write to + * @param reference reference address + * @param data bit(s) to write + * @param writeMultiple whether to use {@link ModbusWriteFunctionCode#WRITE_MULTIPLE_COILS} over + * {@link ModbusWriteFunctionCode#WRITE_COIL}. Useful with single bit of data. + * @param maxTries maximum number of tries in case of errors, should be at least 1 + * @throws IllegalArgumentException in case data is empty, writeMultiple is + * false but there are many bits to write. + */ + public ModbusWriteCoilRequestBlueprint(int subnetId, int slaveId, int reference, BitArray data, + boolean writeMultiple, int maxTries) { + super(); + this.subnetId = subnetId; + this.slaveId = slaveId; + this.reference = reference; + this.bits = data; + this.writeMultiple = writeMultiple; + this.maxTries = maxTries; + + if (!writeMultiple && bits.size() > 1) { + throw new IllegalArgumentException("With multiple coils, writeMultiple must be true"); + } + if (bits.size() == 0) { + throw new IllegalArgumentException("Must have at least one bit"); + } + if (maxTries <= 0) { + throw new IllegalArgumentException("maxTries should be positive, was " + maxTries); + } + } + + @Override + public int getSubnetID() { + return subnetId; + } + + @Override + public int getUnitID() { + return slaveId; + } + + @Override + public int getReference() { + return reference; + } + + @Override + public ModbusWriteFunctionCode getFunctionCode() { + return writeMultiple ? ModbusWriteFunctionCode.WRITE_MULTIPLE_COILS : ModbusWriteFunctionCode.WRITE_COIL; + } + + public BitArray getCoils() { + return bits; + } + + @Override + public int getMaxTries() { + return maxTries; + } + + @Override + public String toString() { + return "ModbusWriteCoilRequestBlueprint [slaveId=" + slaveId + ", reference=" + reference + ", bits=" + bits + + ", maxTries=" + maxTries + ", getFunctionCode()=" + getFunctionCode() + "]"; + } + + @Override + public void accept(ModbusWriteRequestBlueprintVisitor visitor) { + visitor.visit(this); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusWriteFunctionCode.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusWriteFunctionCode.java new file mode 100644 index 00000000000..5c4f5106dae --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusWriteFunctionCode.java @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus; + +import java.util.stream.Stream; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +import ro.ciprianpascu.sbus.Modbus; + +/** + * Modbus write function codes supported by this transport + * + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public enum ModbusWriteFunctionCode { + WRITE_COIL(Modbus.WRITE_COIL), + WRITE_MULTIPLE_COILS(Modbus.WRITE_MULTIPLE_COILS), + WRITE_SINGLE_REGISTER(Modbus.WRITE_SINGLE_REGISTER), + WRITE_MULTIPLE_REGISTERS(Modbus.WRITE_MULTIPLE_REGISTERS); + + private final int functionCode; + + ModbusWriteFunctionCode(int code) { + functionCode = code; + } + + /** + * Get numeric function code represented by this instance + * + * @return + */ + public int getFunctionCode() { + return functionCode; + } + + /** + * Construct {@link ModbusWriteFunctionCode} from the numeric function code + * + * @param functionCode numeric function code + * @return {@link ModbusWriteFunctionCode} matching the numeric function code + * @throws IllegalArgumentException with unsupported functions + */ + public static ModbusWriteFunctionCode fromFunctionCode(int functionCode) throws IllegalArgumentException { + return Stream.of(ModbusWriteFunctionCode.values()).filter(v -> v.getFunctionCode() == functionCode).findFirst() + .orElseThrow(() -> new IllegalArgumentException("Invalid functionCode")); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusWriteRegisterRequestBlueprint.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusWriteRegisterRequestBlueprint.java new file mode 100644 index 00000000000..e408ab6a80c --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusWriteRegisterRequestBlueprint.java @@ -0,0 +1,106 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Implementation for writing registers + * + * @author Sami Salonen - Initial contribution + * + */ +@NonNullByDefault +public class ModbusWriteRegisterRequestBlueprint extends ModbusWriteRequestBlueprint { + + private final int subnetId; + private final int slaveId; + private final int reference; + private final ModbusRegisterArray registers; + private final boolean writeMultiple; + private final int maxTries; + + /** + * Construct coil write request with many bits of data + * + * @param slaveId slave id to write to + * @param reference reference address + * @param registers register(s) to write + * @param writeMultiple whether to use {@link ModbusWriteFunctionCode#WRITE_MULTIPLE_COILS} over + * {@link ModbusWriteFunctionCode#WRITE_COIL}. Useful with single register of data. + * @param maxTries maximum number of tries in case of errors, should be at least 1 + * @throws IllegalArgumentException in case data is empty, writeMultiple is + * false but there are many registers to write. + */ + public ModbusWriteRegisterRequestBlueprint(int subnetId, int slaveId, int reference, ModbusRegisterArray registers, + boolean writeMultiple, int maxTries) throws IllegalArgumentException { + super(); + this.subnetId = subnetId; + this.slaveId = slaveId; + this.reference = reference; + this.registers = registers; + this.writeMultiple = writeMultiple; + this.maxTries = maxTries; + + if (!writeMultiple && registers.size() > 1) { + throw new IllegalArgumentException("With multiple registers, writeMultiple must be true"); + } + if (registers.size() == 0) { + throw new IllegalArgumentException("Must have at least one register"); + } + if (maxTries <= 0) { + throw new IllegalArgumentException("maxTries should be positive"); + } + } + + @Override + public int getReference() { + return reference; + } + + @Override + public int getSubnetID() { + return subnetId; + } + + @Override + public int getUnitID() { + return slaveId; + } + + @Override + public ModbusWriteFunctionCode getFunctionCode() { + return writeMultiple ? ModbusWriteFunctionCode.WRITE_MULTIPLE_REGISTERS + : ModbusWriteFunctionCode.WRITE_SINGLE_REGISTER; + } + + public ModbusRegisterArray getRegisters() { + return registers; + } + + @Override + public int getMaxTries() { + return maxTries; + } + + @Override + public String toString() { + return "ModbusWriteRegisterRequestBlueprint [slaveId=" + slaveId + ", reference=" + reference + ", registers=" + + registers + ", maxTries=" + maxTries + ", getFunctionCode()=" + getFunctionCode() + "]"; + } + + @Override + public void accept(ModbusWriteRequestBlueprintVisitor visitor) { + visitor.visit(this); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusWriteRequestBlueprint.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusWriteRequestBlueprint.java new file mode 100644 index 00000000000..6ab8543f8f7 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusWriteRequestBlueprint.java @@ -0,0 +1,100 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +import ro.ciprianpascu.sbus.Modbus; + +/** + * Base interface for Modbus write requests + * + * @author Sami Salonen - Initial contribution + * + */ +@NonNullByDefault +public abstract class ModbusWriteRequestBlueprint { + + /** + * Returns the protocol identifier of this + * ModbusMessage as int.
+ * The identifier is a 2-byte (short) non negative + * integer value valid in the range of 0-65535. + *

+ * + * @return the protocol identifier as int. + */ + public int getProtocolID() { + return Modbus.DEFAULT_PROTOCOL_ID; + } + + /** + * Returns the reference of the register/coil/discrete input to to start + * writing with this request + *

+ * + * @return the reference of the register + * to start reading from as int. + */ + public abstract int getReference(); + + /** + * Returns the subnet identifier of this + * ModbusMessage as int.
+ * The identifier is a 1-byte non negative + * integer value valid in the range of 0-255. + *

+ * + * @return the unit identifier as int. + */ + public abstract int getSubnetID(); + + /** + * Returns the unit identifier of this + * ModbusMessage as int.
+ * The identifier is a 1-byte non negative + * integer value valid in the range of 0-255. + *

+ * + * @return the unit identifier as int. + */ + public abstract int getUnitID(); + + /** + * Returns the function code of this + * ModbusMessage as int.
+ * The function code is a 1-byte non negative + * integer value valid in the range of 0-127.
+ * Function codes are ordered in conformance + * classes their values are specified in + * ro.ciprianpascu.sbus.Modbus. + *

+ * + * @return the function code as int. + * + * @see ro.ciprianpascu.sbus.Modbus + */ + public abstract ModbusWriteFunctionCode getFunctionCode(); + + /** + * Get maximum number of tries, in case errors occur. Should be at least 1. + */ + public abstract int getMaxTries(); + + /** + * Accept visitor + * + * @param visitor + */ + public abstract void accept(ModbusWriteRequestBlueprintVisitor visitor); +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusWriteRequestBlueprintVisitor.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusWriteRequestBlueprintVisitor.java new file mode 100644 index 00000000000..a874f4e66cc --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ModbusWriteRequestBlueprintVisitor.java @@ -0,0 +1,40 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + *

+ * ModbusWriteRequestBlueprintVisitor interface. + *

+ * + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public interface ModbusWriteRequestBlueprintVisitor { + + /** + * Visit request writing coil data + * + * @param blueprint + */ + void visit(ModbusWriteCoilRequestBlueprint blueprint); + + /** + * Visit request writing register data + * + * @param blueprint + */ + void visit(ModbusWriteRegisterRequestBlueprint blueprint); +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/PollTask.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/PollTask.java new file mode 100644 index 00000000000..483faf51a71 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/PollTask.java @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Poll task represents Modbus read request + * + * Must be hashable. HashCode and equals should be defined such that no two poll tasks are registered that are + * equal. + * + * @author Sami Salonen - Initial contribution + * + * @see ModbusCommunicationInterface#registerRegularPoll + */ +@NonNullByDefault +public interface PollTask extends + TaskWithEndpoint> { + @Override + default int getMaxTries() { + return getRequest().getMaxTries(); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/TaskWithEndpoint.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/TaskWithEndpoint.java new file mode 100644 index 00000000000..18e37f4a914 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/TaskWithEndpoint.java @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.io.transport.sbus.endpoint.ModbusSlaveEndpoint; + +/** + * Common base interface for read and write tasks. + * + * @author Sami Salonen - Initial contribution + * + * @param request type + * @param callback type + */ +@NonNullByDefault +public interface TaskWithEndpoint> { + /** + * Gets endpoint associated with this task + * + * @return + */ + ModbusSlaveEndpoint getEndpoint(); + + /** + * Gets request associated with this task + * + * @return + */ + R getRequest(); + + /** + * Gets the result callback associated with this task, will be called with response + * + * @return + */ + C getResultCallback(); + + /** + * Gets the failure callback associated with this task, will be called in case of an error + * + * @return + */ + F getFailureCallback(); + + int getMaxTries(); +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ValueBuffer.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ValueBuffer.java new file mode 100644 index 00000000000..7d66f2bb189 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/ValueBuffer.java @@ -0,0 +1,331 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus; + +import java.math.BigInteger; +import java.nio.BufferOverflowException; +import java.nio.InvalidMarkException; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * ByteBuffer-like interface for working with different types of data stored in byte arrays + * + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public class ValueBuffer { + private final byte[] bytes; + private final AtomicInteger byteIndex = new AtomicInteger(); + private volatile AtomicReference<@Nullable AtomicInteger> mark = new AtomicReference<>(); + + /** + * Wrap modbus registers and create a new instance of ValueBuffer + * + * The instance will have position of 0. + * + * @param array set of registers + * @return new instance of ValueBuffer referencing bytes represented by modbus register array + */ + public static ValueBuffer wrap(ModbusRegisterArray array) { + return new ValueBuffer(array.getBytes()); + } + + /** + * Wrap given bytes and create a new instance of ValueBuffer + * + * The instance will have position of 0. + * + * + * @param array set of bytes to wrap + * @return new instance of ValueBuffer referencing bytes + */ + public static ValueBuffer wrap(byte[] array) { + return new ValueBuffer(array); + } + + private ValueBuffer(byte[] bytes) { + this.bytes = bytes; + } + + /** + * Returns this buffer's position. + * + * @return The position of this buffer + */ + public int position() { + return byteIndex.get(); + } + + /** + * Sets this buffer's position. If the mark is defined and larger than the new position then it is discarded. + * + * @return this buffer + */ + public ValueBuffer position(int byteIndex) { + this.mark.getAndUpdate(curMark -> { + if (curMark == null) { + return null; + } else if (curMark.get() > byteIndex) { + return null; + } else { + return curMark; + } + }); + this.byteIndex.set(byteIndex); + return this; + } + + /** + * Sets this buffer's mark at its position. + * + * @return this buffer + */ + public ValueBuffer mark() { + mark = new AtomicReference<>(new AtomicInteger(byteIndex.get())); + return this; + } + + /** + * Resets this buffer's position to the previously-marked position. + * Invoking this method neither changes nor discards the mark's value. + * + * @return this buffer + * @throws InvalidMarkException If the mark has not been set + */ + public ValueBuffer reset() throws InvalidMarkException { + int mark = Optional.ofNullable(this.mark.get()).map(AtomicInteger::get).orElse(-1); + if (mark < 0) { + throw new InvalidMarkException(); + } + byteIndex.set(mark); + return this; + } + + /** + * Returns the number of bytes between the current position and the end. + * + * @return The number of bytes remaining in this buffer + */ + public int remaining() { + return bytes.length - byteIndex.get(); + } + + /** + * Returns underlying bytes + * + * @return reference to underlying bytes + */ + public byte[] array() { + return bytes; + } + + /** + * Tells whether there are any bytes left between current position and the end + * + * @return true if, and only if, there is at least one byte remaining in this buffer + */ + public boolean hasRemaining() { + return remaining() > 0; + } + + /** + * Starting from current position, read dst.length number of bytes and copy the data to dst + * + * @param dst copied bytes + * @return this buffer + * @throws BufferOverflowException If there is insufficient space in this buffer for the remaining bytes in the + * source buffer + */ + public ValueBuffer get(byte[] dst) { + int start = byteIndex.getAndAdd(dst.length); + try { + System.arraycopy(bytes, start, dst, 0, dst.length); + } catch (IndexOutOfBoundsException e) { + throw new BufferOverflowException(); + } + return this; + } + + /** + * Extract signed 8-bit integer at current position, and advance position. + * + * @return signed 8-bit integer (byte) + * @see ModbusBitUtilities#extractSInt8 + * @throws IllegalArgumentException when there are not enough bytes in this ValueBuffer + */ + public byte getSInt8() { + return ModbusBitUtilities.extractSInt8(bytes, byteIndex.getAndAdd(1)); + } + + /** + * Extract unsigned 8-bit integer at current position, and advance position. + * + * @return unsigned 8-bit integer + * @see ModbusBitUtilities#extractUInt8 + * @throws IllegalArgumentException when there are not enough bytes in this ValueBuffer + */ + public short getUInt8() { + return ModbusBitUtilities.extractUInt8(bytes, byteIndex.getAndAdd(1)); + } + + /** + * Extract signed 16-bit integer at current position, and advance position. + * + * @return signed 16-bit integer (short) + * @see ModbusBitUtilities#extractSInt16 + * @throws IllegalArgumentException when there are not enough bytes in this ValueBuffer + */ + public short getSInt16() { + return ModbusBitUtilities.extractSInt16(bytes, byteIndex.getAndAdd(2)); + } + + /** + * Extract unsigned 16-bit integer at current position, and advance position. + * + * @return unsigned 16-bit integer + * @see ModbusBitUtilities#extractUInt16 + * @throws IllegalArgumentException when there are not enough bytes in this ValueBuffer + */ + public int getUInt16() { + return ModbusBitUtilities.extractUInt16(bytes, byteIndex.getAndAdd(2)); + } + + /** + * Extract signed 32-bit integer at current position, and advance position. + * + * @return signed 32-bit integer + * @see ModbusBitUtilities#extractSInt32 + * @throws IllegalArgumentException when there are not enough bytes in this ValueBuffer + */ + public int getSInt32() { + return ModbusBitUtilities.extractSInt32(bytes, byteIndex.getAndAdd(4)); + } + + /** + * Extract unsigned 32-bit integer at current position, and advance position. + * + * @return unsigned 32-bit integer + * @see ModbusBitUtilities#extractUInt32 + * @throws IllegalArgumentException when there are not enough bytes in this ValueBuffer + */ + public long getUInt32() { + return ModbusBitUtilities.extractUInt32(bytes, byteIndex.getAndAdd(4)); + } + + /** + * Extract signed 32-bit integer at current position, and advance position. + * + * This is identical with getSInt32, but with registers swapped. + * + * @return signed 32-bit integer + * @see ModbusBitUtilities#extractSInt32Swap + * @throws IllegalArgumentException when there are not enough bytes in this ValueBuffer + */ + public int getSInt32Swap() { + return ModbusBitUtilities.extractSInt32Swap(bytes, byteIndex.getAndAdd(4)); + } + + /** + * Extract unsigned 32-bit integer at current position, and advance position. + * + * This is identical with getUInt32, but with registers swapped. + * + * @return unsigned 32-bit integer + * @see ModbusBitUtilities#extractUInt32Swap + * @throws IllegalArgumentException when there are not enough bytes in this ValueBuffer + */ + public long getUInt32Swap() { + return ModbusBitUtilities.extractUInt32Swap(bytes, byteIndex.getAndAdd(4)); + } + + /** + * Extract signed 64-bit integer at current position, and advance position. + * + * @return signed 64-bit integer + * @see ModbusBitUtilities#extractSInt64 + * @throws IllegalArgumentException when there are not enough bytes in this ValueBuffer + */ + public long getSInt64() { + return ModbusBitUtilities.extractSInt64(bytes, byteIndex.getAndAdd(8)); + } + + /** + * Extract unsigned 64-bit integer at current position, and advance position. + * + * @return unsigned 64-bit integer + * @see ModbusBitUtilities#extractUInt64 + * @throws IllegalArgumentException when there are not enough bytes in this ValueBuffer + */ + public BigInteger getUInt64() { + return ModbusBitUtilities.extractUInt64(bytes, byteIndex.getAndAdd(8)); + } + + /** + * Extract signed 64-bit integer at current position, and advance position. + * + * This is identical with getSInt64, but with registers swapped. + * + * @return signed 64-bit integer + * @see ModbusBitUtilities#extractSInt64Swap + * @throws IllegalArgumentException when there are not enough bytes in this ValueBuffer + */ + public long getSInt64Swap() { + return ModbusBitUtilities.extractSInt64Swap(bytes, byteIndex.getAndAdd(8)); + } + + /** + * Extract unsigned 64-bit integer at current position, and advance position. + * + * This is identical with getUInt64, but with registers swapped. + * + * @return unsigned 64-bit integer + * @see ModbusBitUtilities#extractUInt64Swap + * @throws IllegalArgumentException when there are not enough bytes in this ValueBuffer + */ + public BigInteger getUInt64Swap() { + return ModbusBitUtilities.extractUInt64Swap(bytes, byteIndex.getAndAdd(8)); + } + + /** + * Extract single-precision 32-bit IEEE 754 floating point at current position, and advance position. + * + * Note that this method can return floating point NaN and floating point infinity. + * + * @return single-precision 32-bit IEEE 754 floating point + * @see ModbusBitUtilities#extractFloat32 + * @throws IllegalArgumentException when there are not enough bytes in this ValueBuffer + */ + public float getFloat32() { + return ModbusBitUtilities.extractFloat32(bytes, byteIndex.getAndAdd(4)); + } + + /** + * Extract single-precision 32-bit IEEE 754 floating point at current position, and advance position. + * + * This is identical with getFloat32, but with registers swapped. + * + * Note that this method can return floating point NaN and floating point infinity. + * + * @return single-precision 32-bit IEEE 754 floating point + * @see ModbusBitUtilities#extractFloat32 + * @throws IllegalArgumentException when there are not enough bytes in this ValueBuffer + */ + public float getFloat32Swap() { + return ModbusBitUtilities.extractFloat32Swap(bytes, byteIndex.getAndAdd(4)); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/WriteTask.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/WriteTask.java new file mode 100644 index 00000000000..5295023710d --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/WriteTask.java @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Poll task represents Modbus write request + * + * Unlike {@link PollTask}, this does not have to be hashable. + * + * @author Sami Salonen - Initial contribution + * + */ +@NonNullByDefault +public interface WriteTask extends + TaskWithEndpoint> { + @Override + default int getMaxTries() { + return getRequest().getMaxTries(); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/endpoint/EndpointPoolConfiguration.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/endpoint/EndpointPoolConfiguration.java new file mode 100644 index 00000000000..210e87b0fe0 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/endpoint/EndpointPoolConfiguration.java @@ -0,0 +1,147 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.endpoint; + +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Class representing pooling related configuration of a single endpoint + * + * This class implements equals hashcode constract, and thus is suitable for use as keys in HashMaps, for example. + * + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public class EndpointPoolConfiguration { + + /** + * Delay between first TCP packet and successful TCP connection. + */ + private long afterConnectionDelayMillis; + + /** + * How long should be the minimum duration between previous transaction end and the next transaction with the same + * endpoint. + * + * In milliseconds. + */ + private long interTransactionDelayMillis; + + /** + * How long should be the minimum duration between connection-establishments from the pool (with same endpoint). In + * milliseconds. + */ + private long interConnectDelayMillis; + + /** + * How many times we want to try connecting to the endpoint before giving up. One means that connection + * establishment is tried once. + */ + private int connectMaxTries = 1; + + /** + * Re-connect connection every X milliseconds. Negative means that connection is not disconnected automatically. + * One can use 0ms to denote reconnection after every transaction (default). + */ + private int reconnectAfterMillis; + + /** + * How long before we give up establishing the connection. In milliseconds. Default of 0 means that system/OS + * default is respected. + */ + private int connectTimeoutMillis; + + public void setAfterConnectionDelayMillis(long afterConnectionDelayMillis) { + this.afterConnectionDelayMillis = afterConnectionDelayMillis; + } + + public long getAfterConnectionDelayMillis() { + return afterConnectionDelayMillis; + } + + public long getInterConnectDelayMillis() { + return interConnectDelayMillis; + } + + public void setInterConnectDelayMillis(long interConnectDelayMillis) { + this.interConnectDelayMillis = interConnectDelayMillis; + } + + public int getConnectMaxTries() { + return connectMaxTries; + } + + public void setConnectMaxTries(int connectMaxTries) { + this.connectMaxTries = connectMaxTries; + } + + public int getReconnectAfterMillis() { + return reconnectAfterMillis; + } + + public void setReconnectAfterMillis(int reconnectAfterMillis) { + this.reconnectAfterMillis = reconnectAfterMillis; + } + + public long getInterTransactionDelayMillis() { + return interTransactionDelayMillis; + } + + public void setInterTransactionDelayMillis(long interTransactionDelayMillis) { + this.interTransactionDelayMillis = interTransactionDelayMillis; + } + + public int getConnectTimeoutMillis() { + return connectTimeoutMillis; + } + + public void setConnectTimeoutMillis(int connectTimeoutMillis) { + this.connectTimeoutMillis = connectTimeoutMillis; + } + + @Override + public int hashCode() { + return Objects.hash(connectMaxTries, connectTimeoutMillis, interConnectDelayMillis, interTransactionDelayMillis, + reconnectAfterMillis, afterConnectionDelayMillis); + } + + @Override + public String toString() { + return "EndpointPoolConfiguration [interTransactionDelayMillis=" + interTransactionDelayMillis + + ", interConnectDelayMillis=" + interConnectDelayMillis + ", connectMaxTries=" + connectMaxTries + + ", reconnectAfterMillis=" + reconnectAfterMillis + ", connectTimeoutMillis=" + connectTimeoutMillis + + ", afterConnectionDelayMillis=" + afterConnectionDelayMillis + "]"; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (obj == null) { + return false; + } + if (obj == this) { + return true; + } + if (obj.getClass() != getClass()) { + return false; + } + EndpointPoolConfiguration rhs = (EndpointPoolConfiguration) obj; + return connectMaxTries == rhs.connectMaxTries && connectTimeoutMillis == rhs.connectTimeoutMillis + && interConnectDelayMillis == rhs.interConnectDelayMillis + && interTransactionDelayMillis == rhs.interTransactionDelayMillis + && reconnectAfterMillis == rhs.reconnectAfterMillis + && afterConnectionDelayMillis == rhs.afterConnectionDelayMillis; + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/endpoint/ModbusIPSlaveEndpoint.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/endpoint/ModbusIPSlaveEndpoint.java new file mode 100644 index 00000000000..ea754bfe7d1 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/endpoint/ModbusIPSlaveEndpoint.java @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.endpoint; + +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Common base class for ip based endpoints. Endpoint differentiates different modbus slaves only by the ip address + * (string) and port name. + * + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public abstract class ModbusIPSlaveEndpoint implements ModbusSlaveEndpoint { + + private String address; + private int port; + + public ModbusIPSlaveEndpoint(String address, int port) { + this.address = address; + this.port = port; + } + + public String getAddress() { + return address; + } + + public int getPort() { + return port; + } + + @Override + public int hashCode() { + // differentiate different protocols using the class name, and after that use address and port + return Objects.hash(getClass().getName(), address, port); + } + + @Override + public String toString() { + return "ModbusIPSlaveEndpoint [address=" + address + ", port=" + port + "]"; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (obj == null) { + return false; + } + if (obj == this) { + return true; + } + if (obj.getClass() != getClass()) { + // different protocol -> not equal! + return false; + } + ModbusIPSlaveEndpoint rhs = (ModbusIPSlaveEndpoint) obj; + return Objects.equals(address, rhs.address) && port == rhs.port; + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/endpoint/ModbusSerialSlaveEndpoint.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/endpoint/ModbusSerialSlaveEndpoint.java new file mode 100644 index 00000000000..e7dfa1f2c06 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/endpoint/ModbusSerialSlaveEndpoint.java @@ -0,0 +1,100 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.endpoint; + +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +import ro.ciprianpascu.sbus.util.SerialParameters; + +/** + * Serial endpoint. Endpoint differentiates different modbus slaves only by the serial port. + * port. + * + * Endpoint contains SerialParameters which should be enough to establish the connection. + * + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public class ModbusSerialSlaveEndpoint implements ModbusSlaveEndpoint { + + private SerialParameters serialParameters; + + public ModbusSerialSlaveEndpoint(String portName, int baudRate, int flowControlIn, int flowControlOut, int databits, + int stopbits, int parity, String encoding, boolean echo, int receiveTimeoutMillis) { + this(new SerialParameters(portName, baudRate, flowControlIn, flowControlOut, databits, stopbits, parity, + encoding, echo, receiveTimeoutMillis)); + } + + public ModbusSerialSlaveEndpoint(String portName, int baudRate, String flowControlIn, String flowControlOut, + int databits, String stopbits, String parity, String encoding, boolean echo, int receiveTimeoutMillis) { + SerialParameters parameters = new SerialParameters(); + parameters.setPortName(portName); + parameters.setBaudRate(baudRate); + parameters.setFlowControlIn(flowControlIn); + parameters.setFlowControlOut(flowControlOut); + parameters.setDatabits(databits); + parameters.setStopbits(stopbits); + parameters.setParity(parity); + parameters.setEncoding(encoding); + parameters.setEcho(echo); + parameters.setReceiveTimeoutMillis(receiveTimeoutMillis); + this.serialParameters = parameters; + } + + private ModbusSerialSlaveEndpoint(SerialParameters serialParameters) { + this.serialParameters = serialParameters; + } + + public SerialParameters getSerialParameters() { + return serialParameters; + } + + @Override + public R accept(ModbusSlaveEndpointVisitor factory) { + return factory.visit(this); + } + + public String getPortName() { + return serialParameters.getPortName(); + } + + @Override + public int hashCode() { + // hashcode & equal is determined purely by port name + return serialParameters.getPortName().hashCode(); + } + + @Override + public boolean equals(@Nullable Object obj) { + // equals is determined purely by port name + if (obj == null) { + return false; + } + if (obj == this) { + return true; + } + if (obj.getClass() != getClass()) { + return false; + } + ModbusSerialSlaveEndpoint rhs = (ModbusSerialSlaveEndpoint) obj; + return Objects.equals(getPortName(), rhs.getPortName()); + } + + @Override + public String toString() { + return "ModbusSerialSlaveEndpoint [getPortName()=" + getPortName() + "]"; + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/endpoint/ModbusSlaveEndpoint.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/endpoint/ModbusSlaveEndpoint.java new file mode 100644 index 00000000000..ee4f662c912 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/endpoint/ModbusSlaveEndpoint.java @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.endpoint; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * ModbusSlaveEndpoint contains minimal connection information to establish connection to the slave. End point equals + * and hashCode methods should be implemented such that + * they can be used to differentiate different physical slaves. Read and write transactions are processed + * one at a time if they are associated with the same endpoint (in the sense of equals and hashCode). + * + * Note that, endpoint class might not include all configuration that might be necessary to actually + * communicate with the slave, just the data that is required to establish the connection. + * + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public interface ModbusSlaveEndpoint { + R accept(ModbusSlaveEndpointVisitor visitor); +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/endpoint/ModbusSlaveEndpointVisitor.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/endpoint/ModbusSlaveEndpointVisitor.java new file mode 100644 index 00000000000..766a79076f3 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/endpoint/ModbusSlaveEndpointVisitor.java @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.endpoint; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Visitor for ModbusSlaveEndpoint + * + * @param return type from visit + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public interface ModbusSlaveEndpointVisitor { + @Nullable + R visit(ModbusTCPSlaveEndpoint endpoint); + + @Nullable + R visit(ModbusSerialSlaveEndpoint endpoint); + + @Nullable + R visit(ModbusUDPSlaveEndpoint endpoint); +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/endpoint/ModbusTCPSlaveEndpoint.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/endpoint/ModbusTCPSlaveEndpoint.java new file mode 100644 index 00000000000..a0698be3fe2 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/endpoint/ModbusTCPSlaveEndpoint.java @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.endpoint; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Endpoint for TCP slaves + * + * @author Sami Salonen - Initial contribution + * + */ +@NonNullByDefault +public class ModbusTCPSlaveEndpoint extends ModbusIPSlaveEndpoint { + + private boolean rtuEncoded; + + public ModbusTCPSlaveEndpoint(String address, int port, boolean rtuEncoded) { + super(address, port); + this.rtuEncoded = rtuEncoded; + } + + public boolean getRtuEncoded() { + return rtuEncoded; + } + + @Override + public R accept(ModbusSlaveEndpointVisitor factory) { + return factory.visit(this); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/endpoint/ModbusUDPSlaveEndpoint.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/endpoint/ModbusUDPSlaveEndpoint.java new file mode 100644 index 00000000000..71f93ade457 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/endpoint/ModbusUDPSlaveEndpoint.java @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.endpoint; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Endpoint for UDP slaves + * + * @author Sami Salonen - Initial contribution + * + */ +@NonNullByDefault +public class ModbusUDPSlaveEndpoint extends ModbusIPSlaveEndpoint { + + public ModbusUDPSlaveEndpoint(String address, int port) { + super(address, port); + } + + @Override + public R accept(ModbusSlaveEndpointVisitor factory) { + return factory.visit(this); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/exception/ModbusConnectionException.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/exception/ModbusConnectionException.java new file mode 100644 index 00000000000..1eea1a0c8b7 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/exception/ModbusConnectionException.java @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.exception; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.io.transport.sbus.endpoint.ModbusSlaveEndpoint; + +/** + * Exception for connection issues + * + * @author Sami Salonen - Initial contribution + * + */ +@NonNullByDefault +public class ModbusConnectionException extends ModbusTransportException { + + private static final long serialVersionUID = -6171226761518661925L; + private ModbusSlaveEndpoint endpoint; + + /** + * + * @param endpoint endpoint associated with this exception + */ + public ModbusConnectionException(ModbusSlaveEndpoint endpoint) { + this.endpoint = endpoint; + } + + /** + * Get endpoint associated with this connection error + * + * @return endpoint with the error + */ + public ModbusSlaveEndpoint getEndpoint() { + return endpoint; + } + + @Override + public @Nullable String getMessage() { + return String.format("Error connecting to endpoint %s", endpoint); + } + + @Override + public String toString() { + return String.format("ModbusConnectionException(Error connecting to endpoint=%s)", endpoint); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/exception/ModbusSlaveErrorResponseException.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/exception/ModbusSlaveErrorResponseException.java new file mode 100644 index 00000000000..3de5f5450b3 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/exception/ModbusSlaveErrorResponseException.java @@ -0,0 +1,162 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.exception; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Exception for explicit exception responses from Modbus slave + * + * @author Sami Salonen - Initial contribution + * @author Nagy Attila Gabor - added getter for error type + * + */ +@NonNullByDefault +public abstract class ModbusSlaveErrorResponseException extends ModbusTransportException { + + private static final Map EXCEPTION_CODES_INDEX = new HashMap<>(10); + static { + for (KnownExceptionCode code : KnownExceptionCode.values()) { + EXCEPTION_CODES_INDEX.put(code.exceptionCode, code); + } + } + + /** + * Enum for known exception codes that modbus slaves (servers) can used to indicate exceptions + * + * @author Sami Salonen - Initial contribution + * + */ + public static enum KnownExceptionCode { + ILLEGAL_FUNCTION(1), + ILLEGAL_DATA_ACCESS(2), + ILLEGAL_DATA_VALUE(3), + SLAVE_DEVICE_FAILURE(4), + ACKNOWLEDGE(5), + SLAVE_DEVICE_BUSY(6), + NEGATIVE_ACKNOWLEDGE(7), + MEMORY_PARITY_ERROR(8), + GATEWAY_PATH_UNVAVAILABLE(10), + GATEWAY_TARGET_DEVICE_FAILED_TO_RESPOND(11); + + private final int exceptionCode; + + private KnownExceptionCode(int exceptionCode) { + this.exceptionCode = exceptionCode; + } + + public int getExceptionCode() { + return exceptionCode; + } + + public static Optional tryFromExceptionCode(int exceptionCode) { + return Optional.ofNullable(EXCEPTION_CODES_INDEX.get(exceptionCode)); + } + } + + /** + * The function code received in the query is not an allowable action for the slave. This may be because the + * function code is only applicable to newer devices, and was not implemented in the unit selected. It could + * also + * indicate that the slave is in the wrong state to process a request of this type, for example because it is + * unconfigured and is being asked to return register values. If a Poll Program Complete command was issued, + * this + * code indicates that no program function preceded it. + */ + public static final int ILLEGAL_FUNCTION = KnownExceptionCode.ILLEGAL_FUNCTION.getExceptionCode(); + + /** + * The data address received in the query is not an allowable address for the slave. More specifically, the + * combination of reference number and transfer length is invalid. For a controller with 100 registers, a + * request + * with offset 96 and length 4 would succeed, a request with offset 96 and length 5 will generate exception 02. + */ + public static final int ILLEGAL_DATA_ACCESS = KnownExceptionCode.ILLEGAL_DATA_ACCESS.getExceptionCode(); + + /** + * A value contained in the query data field is not an allowable value for the slave. This indicates a fault in + * the + * structure of remainder of a complex request, such as that the implied length is incorrect. It specifically + * does + * NOT mean that a data item submitted for storage in a register has a value outside the expectation of the + * application program, since the Modbus protocol is unaware of the significance of any particular value of any + * particular register. + */ + public static final int ILLEGAL_DATA_VALUE = KnownExceptionCode.ILLEGAL_DATA_VALUE.getExceptionCode(); + + /** + * An unrecoverable error occurred while the slave was attempting to perform the requested action. + */ + public static final int SLAVE_DEVICE_FAILURE = KnownExceptionCode.SLAVE_DEVICE_FAILURE.getExceptionCode(); + + /** + * Specialized use in conjunction with programming commands. + * The slave has accepted the request and is processing it, but a long duration of time will be required to do + * so. + * This response is returned to prevent a timeout error from occurring in the master. The master can next issue + * a + * Poll Program Complete message to determine if processing is completed. + */ + public static final int ACKNOWLEDGE = KnownExceptionCode.ACKNOWLEDGE.getExceptionCode(); + + /** + * Specialized use in conjunction with programming commands. + * The slave is engaged in processing a long-duration program command. The master should retransmit the message + * later when the slave is free. + */ + public static final int SLAVE_DEVICE_BUSY = KnownExceptionCode.SLAVE_DEVICE_BUSY.getExceptionCode(); + + /** + * The slave cannot perform the program function received in the query. This code is returned for an + * unsuccessful + * programming request using function code 13 or 14 decimal. The master should request diagnostic or error + * information from the slave. + */ + public static final int NEGATIVE_ACKNOWLEDGE = KnownExceptionCode.ACKNOWLEDGE.getExceptionCode(); + + /** + * Specialized use in conjunction with function codes 20 and 21 and reference type 6, to indicate that the + * extended + * file area failed to pass a consistency check. + * The slave attempted to read extended memory or record file, but detected a parity error in memory. The master + * can + * retry the request, but service may be required on the slave device. + */ + public static final int MEMORY_PARITY_ERROR = KnownExceptionCode.MEMORY_PARITY_ERROR.getExceptionCode(); + + /** + * Specialized use in conjunction with gateways, indicates that the gateway was unable to allocate an internal + * communication path from the input port to the output port for processing the request. Usually means the + * gateway + * is misconfigured or overloaded. + */ + public static final int GATEWAY_PATH_UNVAVAILABLE = KnownExceptionCode.GATEWAY_PATH_UNVAVAILABLE.getExceptionCode(); + + /** + * Specialized use in conjunction with gateways, indicates that no response was obtained from the target device. + * Usually means that the device is not present on the network. + */ + public static final int GATEWAY_TARGET_DEVICE_FAILED_TO_RESPOND = KnownExceptionCode.GATEWAY_TARGET_DEVICE_FAILED_TO_RESPOND + .getExceptionCode(); + + private static final long serialVersionUID = -1435199498550990487L; + + /** + * @return the Modbus exception code that happened + */ + public abstract int getExceptionCode(); +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/exception/ModbusSlaveIOException.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/exception/ModbusSlaveIOException.java new file mode 100644 index 00000000000..fa31776cdfc --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/exception/ModbusSlaveIOException.java @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.exception; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Exception for all IO errors + * + * @author Sami Salonen - Initial contribution + * + */ +@NonNullByDefault +public class ModbusSlaveIOException extends ModbusTransportException { + + private static final long serialVersionUID = -8568199166837844463L; +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/exception/ModbusTransportException.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/exception/ModbusTransportException.java new file mode 100644 index 00000000000..faf00b0c734 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/exception/ModbusTransportException.java @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.exception; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Base exception for all exceptions in Modbus transport bundle + * + * @author Sami Salonen - Initial contribution + * + */ +@NonNullByDefault +public class ModbusTransportException extends Exception { + + private static final long serialVersionUID = 1684767401685843339L; +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/exception/ModbusUnexpectedResponseFunctionCodeException.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/exception/ModbusUnexpectedResponseFunctionCodeException.java new file mode 100644 index 00000000000..98772a3dab4 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/exception/ModbusUnexpectedResponseFunctionCodeException.java @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.exception; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Exception representing situation where function code of the response does not match request + * + * @author Sami Salonen - Initial contribution + * + */ +@NonNullByDefault +public class ModbusUnexpectedResponseFunctionCodeException extends ModbusTransportException { + + private static final long serialVersionUID = 1109165449703638949L; + private int requestFunctionCode; + private int responseFunctionCode; + + public ModbusUnexpectedResponseFunctionCodeException(int requestFunctionCode, int responseFunctionCode) { + this.requestFunctionCode = requestFunctionCode; + this.responseFunctionCode = responseFunctionCode; + } + + @Override + public @Nullable String getMessage() { + return String.format("Function code of request (%d) does not equal response (%d)", requestFunctionCode, + responseFunctionCode); + } + + @Override + public String toString() { + return String.format( + "ModbusUnexpectedResponseFunctionCodeException(requestFunctionCode=%d, responseFunctionCode=%d)", + requestFunctionCode, responseFunctionCode); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/exception/ModbusUnexpectedResponseSizeException.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/exception/ModbusUnexpectedResponseSizeException.java new file mode 100644 index 00000000000..9e8c593d907 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/exception/ModbusUnexpectedResponseSizeException.java @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.exception; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Exception representing situation where data length of the response does not match request + * + * @author Sami Salonen - Initial contribution + * + */ +@NonNullByDefault +public class ModbusUnexpectedResponseSizeException extends ModbusTransportException { + + private static final long serialVersionUID = 2460907938819984483L; + private int requestSize; + private int responseSize; + + public ModbusUnexpectedResponseSizeException(int requestSize, int responseSize) { + this.requestSize = requestSize; + this.responseSize = responseSize; + } + + @Override + public @Nullable String getMessage() { + return String.format("Data length of the request (%d) does not equal response (%d). Slave response is invalid.", + requestSize, responseSize); + } + + @Override + public String toString() { + return String.format("ModbusUnexpectedResponseSizeException(requestFunctionCode=%d, responseFunctionCode=%d)", + requestSize, responseSize); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/exception/ModbusUnexpectedTransactionIdException.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/exception/ModbusUnexpectedTransactionIdException.java new file mode 100644 index 00000000000..cc4490ec0c3 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/exception/ModbusUnexpectedTransactionIdException.java @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.exception; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Exception representing situation where transaction id of the response does not match request + * + * @author Sami Salonen - Initial contribution + * + */ +@NonNullByDefault +public class ModbusUnexpectedTransactionIdException extends ModbusTransportException { + + private static final long serialVersionUID = -2453232634024813933L; + private int requestId; + private int responseId; + + public ModbusUnexpectedTransactionIdException(int requestId, int responseId) { + this.requestId = requestId; + this.responseId = responseId; + } + + @Override + public @Nullable String getMessage() { + return String.format("Transaction id of request (%d) does not equal response (%d). Slave response is invalid.", + requestId, responseId); + } + + @Override + public String toString() { + return String.format( + "ModbusUnexpectedTransactionIdException(requestTransactionId=%d, responseTransactionId=%d)", requestId, + responseId); + } + + public int getRequestId() { + return requestId; + } + + public int getResponseId() { + return responseId; + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/AggregateStopWatch.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/AggregateStopWatch.java new file mode 100644 index 00000000000..1d54288731f --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/AggregateStopWatch.java @@ -0,0 +1,72 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.internal; + +import java.util.UUID; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * Utility for timing operations + * + * @author Sami Salonen - initial contribution + * + */ +@NonNullByDefault +public class AggregateStopWatch { + /** + * ID associated with this modbus operation + */ + final String operationId; + + /** + * Total operation time + */ + final SimpleStopWatch total = new SimpleStopWatch(); + + /** + * Time for connection related actions + */ + final SimpleStopWatch connection = new SimpleStopWatch(); + + /** + * Time for actual the actual transaction (read/write to slave) + */ + final SimpleStopWatch transaction = new SimpleStopWatch(); + + /** + * Time for calling calling the callback + */ + final SimpleStopWatch callback = new SimpleStopWatch(); + + public AggregateStopWatch() { + this.operationId = UUID.randomUUID().toString(); + } + + /** + * Suspend all running stopwatches of this aggregate + */ + public void suspendAllRunning() { + for (SimpleStopWatch watch : new SimpleStopWatch[] { total, connection, transaction, callback }) { + if (watch.isRunning()) { + watch.suspend(); + } + } + } + + @Override + public String toString() { + return String.format("{total: %d ms, connection: %d, transaction=%d, callback=%d}", total.getTotalTimeMillis(), + connection.getTotalTimeMillis(), transaction.getTotalTimeMillis(), callback.getTotalTimeMillis()); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/BasicPollTask.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/BasicPollTask.java new file mode 100644 index 00000000000..94bcc14b120 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/BasicPollTask.java @@ -0,0 +1,99 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.internal; + +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.io.transport.sbus.ModbusFailureCallback; +import org.openhab.core.io.transport.sbus.ModbusReadCallback; +import org.openhab.core.io.transport.sbus.ModbusReadRequestBlueprint; +import org.openhab.core.io.transport.sbus.PollTask; +import org.openhab.core.io.transport.sbus.endpoint.ModbusSlaveEndpoint; + +/** + * Implementation of {@link PollTask} that differentiates tasks using endpoint, request and callbacks. + * + * Note: Two differentiate poll tasks are considered unequal if their callbacks are unequal. + * + * HashCode and equals should be defined such that two poll tasks considered the same only if their request, + * maxTries, endpoint and callback are the same. + * + * @author Sami Salonen - Initial contribution + * + */ +@NonNullByDefault +public class BasicPollTask implements PollTask { + + private ModbusSlaveEndpoint endpoint; + private ModbusReadRequestBlueprint request; + private ModbusReadCallback resultCallback; + private ModbusFailureCallback failureCallback; + + public BasicPollTask(ModbusSlaveEndpoint endpoint, ModbusReadRequestBlueprint request, + ModbusReadCallback resultCallback, ModbusFailureCallback failureCallback) { + this.endpoint = endpoint; + this.request = request; + this.resultCallback = resultCallback; + this.failureCallback = failureCallback; + } + + @Override + public ModbusReadRequestBlueprint getRequest() { + return request; + } + + @Override + public ModbusSlaveEndpoint getEndpoint() { + return endpoint; + } + + @Override + public ModbusReadCallback getResultCallback() { + return resultCallback; + } + + @Override + public ModbusFailureCallback getFailureCallback() { + return failureCallback; + } + + @Override + public int hashCode() { + return Objects.hash(request, getEndpoint(), getResultCallback(), getFailureCallback()); + } + + @Override + public String toString() { + return "BasicPollTask [getEndpoint=" + getEndpoint() + ", request=" + request + ", getResultCallback()=" + + getResultCallback() + ", getFailureCallback()=" + getFailureCallback() + "]"; + } + + @Override + public boolean equals(@Nullable Object obj) { + if (obj == null) { + return false; + } + if (obj == this) { + return true; + } + if (obj.getClass() != getClass()) { + return false; + } + BasicPollTask rhs = (BasicPollTask) obj; + return Objects.equals(request, rhs.request) && Objects.equals(getEndpoint(), rhs.getEndpoint()) + && Objects.equals(getResultCallback(), rhs.getResultCallback()) + && Objects.equals(getFailureCallback(), rhs.getFailureCallback()); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/BasicWriteTask.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/BasicWriteTask.java new file mode 100644 index 00000000000..fe2ebc94043 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/BasicWriteTask.java @@ -0,0 +1,70 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.io.transport.sbus.ModbusFailureCallback; +import org.openhab.core.io.transport.sbus.ModbusWriteCallback; +import org.openhab.core.io.transport.sbus.ModbusWriteRequestBlueprint; +import org.openhab.core.io.transport.sbus.WriteTask; +import org.openhab.core.io.transport.sbus.endpoint.ModbusSlaveEndpoint; + +/** + * Simple implementation for Modbus write requests + * + * @author Sami Salonen - Initial contribution + * + */ +@NonNullByDefault +public class BasicWriteTask implements WriteTask { + + private ModbusSlaveEndpoint endpoint; + private ModbusWriteRequestBlueprint request; + private ModbusWriteCallback resultCallback; + private ModbusFailureCallback failureCallback; + + public BasicWriteTask(ModbusSlaveEndpoint endpoint, ModbusWriteRequestBlueprint request, + ModbusWriteCallback resultCallback, ModbusFailureCallback failureCallback) { + super(); + this.endpoint = endpoint; + this.request = request; + this.resultCallback = resultCallback; + this.failureCallback = failureCallback; + } + + @Override + public ModbusSlaveEndpoint getEndpoint() { + return endpoint; + } + + @Override + public ModbusWriteRequestBlueprint getRequest() { + return request; + } + + @Override + public ModbusWriteCallback getResultCallback() { + return resultCallback; + } + + @Override + public ModbusFailureCallback getFailureCallback() { + return failureCallback; + } + + @Override + public String toString() { + return "BasicWriteTask [endpoint=" + endpoint + ", request=" + request + ", resultCallback=" + resultCallback + + ", failureCallback=" + failureCallback + "]"; + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/ModbusConnectionPool.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/ModbusConnectionPool.java new file mode 100644 index 00000000000..62023e9d968 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/ModbusConnectionPool.java @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.internal; + +import org.apache.commons.pool2.KeyedPooledObjectFactory; +import org.apache.commons.pool2.impl.GenericKeyedObjectPool; +import org.apache.commons.pool2.impl.GenericKeyedObjectPoolConfig; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.io.transport.sbus.endpoint.ModbusSlaveEndpoint; + +import ro.ciprianpascu.sbus.net.ModbusSlaveConnection; + +/** + * Pool for modbus connections. + * + * Only one connection is allowed to be active at a time. + * + * @author Sami Salonen - Initial contribution + * + */ +@NonNullByDefault +public class ModbusConnectionPool extends GenericKeyedObjectPool { + + public ModbusConnectionPool( + KeyedPooledObjectFactory factory) { + super(factory, new ModbusPoolConfig()); + } + + @Override + public void setConfig(@Nullable GenericKeyedObjectPoolConfig<@Nullable ModbusSlaveConnection> conf) { + if (conf == null) { + return; + } else if (!(conf instanceof ModbusPoolConfig)) { + throw new IllegalArgumentException("Only ModbusPoolConfig accepted!"); + } + super.setConfig(conf); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/ModbusLibraryWrapper.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/ModbusLibraryWrapper.java new file mode 100644 index 00000000000..626583b3bff --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/ModbusLibraryWrapper.java @@ -0,0 +1,346 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.internal; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.IntStream; + +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.io.transport.sbus.AsyncModbusReadResult; +import org.openhab.core.io.transport.sbus.BitArray; +import org.openhab.core.io.transport.sbus.ModbusReadCallback; +import org.openhab.core.io.transport.sbus.ModbusReadFunctionCode; +import org.openhab.core.io.transport.sbus.ModbusReadRequestBlueprint; +import org.openhab.core.io.transport.sbus.ModbusRegisterArray; +import org.openhab.core.io.transport.sbus.ModbusWriteCoilRequestBlueprint; +import org.openhab.core.io.transport.sbus.ModbusWriteRegisterRequestBlueprint; +import org.openhab.core.io.transport.sbus.ModbusWriteRequestBlueprint; +import org.openhab.core.io.transport.sbus.ModbusWriteRequestBlueprintVisitor; +import org.openhab.core.io.transport.sbus.endpoint.ModbusSerialSlaveEndpoint; +import org.openhab.core.io.transport.sbus.endpoint.ModbusSlaveEndpoint; +import org.openhab.core.io.transport.sbus.endpoint.ModbusSlaveEndpointVisitor; +import org.openhab.core.io.transport.sbus.endpoint.ModbusTCPSlaveEndpoint; +import org.openhab.core.io.transport.sbus.endpoint.ModbusUDPSlaveEndpoint; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ro.ciprianpascu.sbus.io.ModbusSerialTransaction; +import ro.ciprianpascu.sbus.io.ModbusTCPTransaction; +import ro.ciprianpascu.sbus.io.ModbusTransaction; +import ro.ciprianpascu.sbus.io.ModbusUDPTransaction; +import ro.ciprianpascu.sbus.msg.ModbusRequest; +import ro.ciprianpascu.sbus.msg.ModbusResponse; +import ro.ciprianpascu.sbus.msg.ReadCoilsRequest; +import ro.ciprianpascu.sbus.msg.ReadCoilsResponse; +import ro.ciprianpascu.sbus.msg.ReadInputDiscretesRequest; +import ro.ciprianpascu.sbus.msg.ReadInputDiscretesResponse; +import ro.ciprianpascu.sbus.msg.ReadInputRegistersRequest; +import ro.ciprianpascu.sbus.msg.ReadInputRegistersResponse; +import ro.ciprianpascu.sbus.msg.ReadMultipleRegistersRequest; +import ro.ciprianpascu.sbus.msg.ReadMultipleRegistersResponse; +import ro.ciprianpascu.sbus.msg.WriteCoilRequest; +import ro.ciprianpascu.sbus.msg.WriteMultipleCoilsRequest; +import ro.ciprianpascu.sbus.msg.WriteMultipleRegistersRequest; +import ro.ciprianpascu.sbus.msg.WriteSingleRegisterRequest; +import ro.ciprianpascu.sbus.net.ModbusSlaveConnection; +import ro.ciprianpascu.sbus.net.SerialConnection; +import ro.ciprianpascu.sbus.net.TCPMasterConnection; +import ro.ciprianpascu.sbus.net.UDPMasterConnection; +import ro.ciprianpascu.sbus.procimg.InputRegister; +import ro.ciprianpascu.sbus.procimg.Register; +import ro.ciprianpascu.sbus.procimg.SimpleInputRegister; +import ro.ciprianpascu.sbus.util.BitVector; + +/** + * Conversion utilities between underlying Modbus library (ro.ciprianpascu.sbus) and this transport bundle + * + * @author Sami Salonen - Initial contribution + * + */ +@NonNullByDefault +public class ModbusLibraryWrapper { + + private static Logger getLogger() { + return LoggerFactory.getLogger(ModbusLibraryWrapper.class); + } + + private static BitArray bitArrayFromBitVector(BitVector bitVector, int count) { + boolean[] bits = new boolean[count]; + for (int i = 0; i < count; i++) { + bits[i] = bitVector.getBit(i); + } + return new BitArray(bits); + } + + private static ModbusRegisterArray modbusRegisterArrayFromInputRegisters(InputRegister[] inputRegisters) { + int[] registers = new int[inputRegisters.length]; + for (int i = 0; i < inputRegisters.length; i++) { + registers[i] = inputRegisters[i].getValue(); + } + return new ModbusRegisterArray(registers); + } + + /** + * Convert the general request to Modbus library request object + * + * @param message + * @throws IllegalArgumentException + * 1) in case function code implies coil data but we have registers + * 2) in case function code implies register data but we have coils + * 3) in case there is no data + * 4) in case there is too much data in case of WRITE_COIL or WRITE_SINGLE_REGISTER + * @throws IllegalStateException unexpected function code. Implementation is lacking and this can be considered a + * bug + * @return MODBUS library request matching the write request + */ + public static ModbusRequest createRequest(ModbusWriteRequestBlueprint message) { + // ModbusRequest[] request = new ModbusRequest[1]; + AtomicReference request = new AtomicReference<>(); + AtomicBoolean writeSingle = new AtomicBoolean(false); + switch (message.getFunctionCode()) { + case WRITE_COIL: + writeSingle.set(true); + // fall-through on purpose + case WRITE_MULTIPLE_COILS: + message.accept(new ModbusWriteRequestBlueprintVisitor() { + + @Override + public void visit(ModbusWriteRegisterRequestBlueprint blueprint) { + throw new IllegalArgumentException(); + } + + @Override + public void visit(ModbusWriteCoilRequestBlueprint blueprint) { + BitArray coils = blueprint.getCoils(); + if (coils.size() == 0) { + throw new IllegalArgumentException("Must provide at least one coil"); + } + if (writeSingle.get()) { + if (coils.size() != 1) { + throw new IllegalArgumentException("Must provide single coil with WRITE_COIL"); + } + request.set(new WriteCoilRequest(message.getReference(), coils.getBit(0))); + } else { + request.set(new WriteMultipleCoilsRequest(message.getReference(), + ModbusLibraryWrapper.convertBits(coils))); + } + } + }); + break; + case WRITE_SINGLE_REGISTER: + writeSingle.set(true); + // fall-through on purpose + case WRITE_MULTIPLE_REGISTERS: + message.accept(new ModbusWriteRequestBlueprintVisitor() { + + @Override + public void visit(ModbusWriteRegisterRequestBlueprint blueprint) { + Register[] registers = ModbusLibraryWrapper.convertRegisters(blueprint.getRegisters()); + if (registers.length == 0) { + throw new IllegalArgumentException("Must provide at least one register"); + } + if (writeSingle.get()) { + if (blueprint.getRegisters().size() != 1) { + throw new IllegalArgumentException( + "Must provide single register with WRITE_SINGLE_REGISTER"); + } + request.set(new WriteSingleRegisterRequest(message.getReference(), registers[0])); + } else { + request.set(new WriteMultipleRegistersRequest(message.getReference(), registers)); + } + } + + @Override + public void visit(ModbusWriteCoilRequestBlueprint blueprint) { + throw new IllegalArgumentException(); + } + }); + break; + default: + getLogger().error("Unexpected function code {}", message.getFunctionCode()); + throw new IllegalStateException( + String.format("Unexpected function code %s", message.getFunctionCode())); + } + ModbusRequest modbusRequest = request.get(); + modbusRequest.setUnitID(message.getUnitID()); + modbusRequest.setProtocolID(message.getProtocolID()); + return modbusRequest; + } + + /** + * Create a fresh transaction for the given endpoint and connection + * + * The retries of the transaction will be disabled. + * + * @param endpoint + * @param connection + * @return + */ + public static ModbusTransaction createTransactionForEndpoint(ModbusSlaveEndpoint endpoint, + ModbusSlaveConnection connection) { + ModbusTransaction transaction = endpoint.accept(new ModbusSlaveEndpointVisitor() { + + @Override + public @NonNull ModbusTransaction visit(ModbusTCPSlaveEndpoint modbusIPSlavePoolingKey) { + ModbusTCPTransaction transaction = new ModbusTCPTransaction(); + transaction.setReconnecting(false); + return transaction; + } + + @Override + public @NonNull ModbusTransaction visit(ModbusSerialSlaveEndpoint modbusSerialSlavePoolingKey) { + return new ModbusSerialTransaction(); + } + + @Override + public @NonNull ModbusTransaction visit(ModbusUDPSlaveEndpoint modbusUDPSlavePoolingKey) { + return new ModbusUDPTransaction(); + } + }); + // We disable modbus library retries and handle in the Manager implementation + transaction.setRetries(0); + transaction.setRetryDelayMillis(0); + if (transaction instanceof ModbusSerialTransaction serialTransaction) { + serialTransaction.setSerialConnection((SerialConnection) connection); + } else if (transaction instanceof ModbusUDPTransaction pTransaction) { + pTransaction.setTerminal(((UDPMasterConnection) connection).getTerminal()); + } else if (transaction instanceof ModbusTCPTransaction pTransaction) { + pTransaction.setConnection((TCPMasterConnection) connection); + } else { + throw new IllegalStateException(); + } + return transaction; + } + + /** + * Create fresh request corresponding to {@link ModbusReadRequestBlueprint} + * + * @param message + * @return + */ + public static ModbusRequest createRequest(ModbusReadRequestBlueprint message) { + ModbusRequest request; + if (message.getFunctionCode() == ModbusReadFunctionCode.READ_COILS) { + request = new ReadCoilsRequest(message.getReference(), message.getDataLength()); + } else if (message.getFunctionCode() == ModbusReadFunctionCode.READ_INPUT_DISCRETES) { + request = new ReadInputDiscretesRequest(message.getReference(), message.getDataLength()); + } else if (message.getFunctionCode() == ModbusReadFunctionCode.READ_MULTIPLE_REGISTERS) { + request = new ReadMultipleRegistersRequest(message.getReference(), message.getDataLength()); + } else if (message.getFunctionCode() == ModbusReadFunctionCode.READ_INPUT_REGISTERS) { + request = new ReadInputRegistersRequest(message.getReference(), message.getDataLength()); + } else { + throw new IllegalArgumentException(String.format("Unexpected function code %s", message.getFunctionCode())); + } + request.setUnitID(message.getUnitID()); + request.setProtocolID(message.getProtocolID()); + + return request; + } + + /** + * Convert {@link BitArray} to {@link BitVector} + * + * @param bits + * @return + */ + public static BitVector convertBits(BitArray bits) { + BitVector bitVector = new BitVector(bits.size()); + IntStream.range(0, bits.size()).forEach(i -> bitVector.setBit(i, bits.getBit(i))); + return bitVector; + } + + /** + * Convert {@link ModbusRegisterArray} to array of {@link Register} + * + * @param bits + * @return + */ + public static Register[] convertRegisters(ModbusRegisterArray arr) { + return IntStream.range(0, arr.size()).mapToObj(i -> new SimpleInputRegister(arr.getRegister(i))).toList() + .toArray(new Register[0]); + } + + /** + * Get number of bits/registers/discrete inputs in the request. + * + * + * @param response + * @param request + * @return + */ + public static int getNumberOfItemsInResponse(ModbusResponse response, ModbusReadRequestBlueprint request) { + // jamod library seems to be a bit buggy when it comes number of coils/discrete inputs in the response. Some + // of the methods such as ReadCoilsResponse.getBitCount() are returning wrong values. + // + // This is the reason we use a bit more verbose way to get the number of items in the response. + final int responseCount; + if (request.getFunctionCode() == ModbusReadFunctionCode.READ_COILS) { + responseCount = ((ReadCoilsResponse) response).getCoils().size(); + } else if (request.getFunctionCode() == ModbusReadFunctionCode.READ_INPUT_DISCRETES) { + responseCount = ((ReadInputDiscretesResponse) response).getDiscretes().size(); + } else if (request.getFunctionCode() == ModbusReadFunctionCode.READ_MULTIPLE_REGISTERS) { + responseCount = ((ReadMultipleRegistersResponse) response).getRegisters().length; + } else if (request.getFunctionCode() == ModbusReadFunctionCode.READ_INPUT_REGISTERS) { + responseCount = ((ReadInputRegistersResponse) response).getRegisters().length; + } else { + throw new IllegalArgumentException(String.format("Unexpected function code %s", request.getFunctionCode())); + } + return responseCount; + } + + /** + * Invoke callback with the data received + * + * @param message original request + * @param callback callback for read + * @param response Modbus library response object + */ + public static void invokeCallbackWithResponse(ModbusReadRequestBlueprint request, ModbusReadCallback callback, + ModbusResponse response) { + try { + getLogger().trace("Calling read response callback {} for request {}. Response was {}", callback, request, + response); + // The number of coils/discrete inputs received in response are always in the multiples of 8 + // bits. + // So even if querying 5 bits, you will actually get 8 bits. Here we wrap the data in + // BitArrayWrappingBitVector + // with will validate that the consumer is not accessing the "invalid" bits of the response. + int dataItemsInResponse = getNumberOfItemsInResponse(response, request); + if (request.getFunctionCode() == ModbusReadFunctionCode.READ_COILS) { + BitVector bits = ((ReadCoilsResponse) response).getCoils(); + BitArray payload = bitArrayFromBitVector(bits, Math.min(dataItemsInResponse, request.getDataLength())); + callback.handle(new AsyncModbusReadResult(request, payload)); + } else if (request.getFunctionCode() == ModbusReadFunctionCode.READ_INPUT_DISCRETES) { + BitVector bits = ((ReadInputDiscretesResponse) response).getDiscretes(); + BitArray payload = bitArrayFromBitVector(bits, Math.min(dataItemsInResponse, request.getDataLength())); + callback.handle(new AsyncModbusReadResult(request, payload)); + } else if (request.getFunctionCode() == ModbusReadFunctionCode.READ_MULTIPLE_REGISTERS) { + ModbusRegisterArray payload = modbusRegisterArrayFromInputRegisters( + ((ReadMultipleRegistersResponse) response).getRegisters()); + callback.handle(new AsyncModbusReadResult(request, payload)); + } else if (request.getFunctionCode() == ModbusReadFunctionCode.READ_INPUT_REGISTERS) { + ModbusRegisterArray payload = modbusRegisterArrayFromInputRegisters( + ((ReadInputRegistersResponse) response).getRegisters()); + callback.handle(new AsyncModbusReadResult(request, payload)); + } else { + throw new IllegalArgumentException( + String.format("Unexpected function code %s", request.getFunctionCode())); + } + } finally { + getLogger().trace("Called read response callback {} for request {}. Response was {}", callback, request, + response); + } + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/ModbusManagerImpl.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/ModbusManagerImpl.java new file mode 100644 index 00000000000..c3cb4d27e91 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/ModbusManagerImpl.java @@ -0,0 +1,1042 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.internal; + +import java.io.IOException; +import java.util.LinkedList; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +import javax.imageio.IIOException; + +import org.apache.commons.pool2.KeyedObjectPool; +import org.apache.commons.pool2.SwallowedExceptionListener; +import org.apache.commons.pool2.impl.GenericKeyedObjectPool; +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.common.ThreadPoolManager; +import org.openhab.core.io.transport.sbus.AsyncModbusFailure; +import org.openhab.core.io.transport.sbus.AsyncModbusWriteResult; +import org.openhab.core.io.transport.sbus.ModbusCommunicationInterface; +import org.openhab.core.io.transport.sbus.ModbusFailureCallback; +import org.openhab.core.io.transport.sbus.ModbusManager; +import org.openhab.core.io.transport.sbus.ModbusReadCallback; +import org.openhab.core.io.transport.sbus.ModbusReadRequestBlueprint; +import org.openhab.core.io.transport.sbus.ModbusResultCallback; +import org.openhab.core.io.transport.sbus.ModbusWriteCallback; +import org.openhab.core.io.transport.sbus.ModbusWriteRequestBlueprint; +import org.openhab.core.io.transport.sbus.PollTask; +import org.openhab.core.io.transport.sbus.TaskWithEndpoint; +import org.openhab.core.io.transport.sbus.WriteTask; +import org.openhab.core.io.transport.sbus.endpoint.EndpointPoolConfiguration; +import org.openhab.core.io.transport.sbus.endpoint.ModbusSerialSlaveEndpoint; +import org.openhab.core.io.transport.sbus.endpoint.ModbusSlaveEndpoint; +import org.openhab.core.io.transport.sbus.endpoint.ModbusSlaveEndpointVisitor; +import org.openhab.core.io.transport.sbus.endpoint.ModbusTCPSlaveEndpoint; +import org.openhab.core.io.transport.sbus.endpoint.ModbusUDPSlaveEndpoint; +import org.openhab.core.io.transport.sbus.exception.ModbusConnectionException; +import org.openhab.core.io.transport.sbus.exception.ModbusUnexpectedResponseFunctionCodeException; +import org.openhab.core.io.transport.sbus.exception.ModbusUnexpectedResponseSizeException; +import org.openhab.core.io.transport.sbus.exception.ModbusUnexpectedTransactionIdException; +import org.openhab.core.io.transport.sbus.internal.pooling.ModbusSlaveConnectionFactoryImpl; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ro.ciprianpascu.sbus.Modbus; +import ro.ciprianpascu.sbus.ModbusException; +import ro.ciprianpascu.sbus.ModbusIOException; +import ro.ciprianpascu.sbus.ModbusSlaveException; +import ro.ciprianpascu.sbus.io.ModbusTransaction; +import ro.ciprianpascu.sbus.msg.ModbusRequest; +import ro.ciprianpascu.sbus.msg.ModbusResponse; +import ro.ciprianpascu.sbus.net.ModbusSlaveConnection; + +/** + * Main implementation of ModbusManager + * + * We use connection pool to ensure that only single transaction is ongoing per each endpoint. This is especially + * important with serial slaves but practice has shown that even many tcp slaves have limited + * capability to handle many connections at the same time + * + * @author Sami Salonen - Initial contribution + */ +@Component(service = ModbusManager.class, configurationPid = "transport.modbus") +@NonNullByDefault +public class ModbusManagerImpl implements ModbusManager { + + static class PollTaskUnregistered extends Exception { + public PollTaskUnregistered(String msg) { + super(msg); + } + + private static final long serialVersionUID = 6939730579178506885L; + } + + @FunctionalInterface + private interface ModbusOperation { + + /** + * Execute the operation. + * + * All errors should be raised. There should not be any retry mechanism implemented at this level + * + * @param timer aggregate stop watch for performance profiling + * @param task task to execute + * @param connection connection to use + * + * @throws IIOException on generic IO errors + * @throws ModbusException on Modbus protocol errors (e.g. ModbusIOException on I/O, ModbusSlaveException on + * slave exception responses) + * @throws ModbusUnexpectedTransactionIdException when transaction IDs of the request and + * response do not match + * @throws ModbusUnexpectedResponseFunctionCodeException when response function code does not match the request + * (ill-behaving slave) + * @throws ModbusUnexpectedResponseSizeException when data length of the response and request do not match + */ + public void accept(AggregateStopWatch timer, T task, ModbusSlaveConnection connection) + throws ModbusException, IIOException, ModbusUnexpectedTransactionIdException, + ModbusUnexpectedResponseFunctionCodeException, ModbusUnexpectedResponseSizeException; + } + + /** + * Check that transaction id of the response and request match + * + * @param response response from the slave corresponding to request + * @param libRequest modbus request + * @param operationId operation id for logging + * @throws ModbusUnexpectedTransactionIdException when transaction IDs of the request and + * response do not match + */ + private void checkTransactionId(ModbusResponse response, ModbusRequest libRequest, String operationId) + throws ModbusUnexpectedTransactionIdException { + // Compare request and response transaction ID. NOTE: ModbusTransaction.getTransactionID() is static and + // not safe to use + if ((response.getTransactionID() != libRequest.getTransactionID()) && !response.isHeadless()) { + throw new ModbusUnexpectedTransactionIdException(libRequest.getTransactionID(), + response.getTransactionID()); + } + } + + /** + * Check that function code of the response and request match + * + * @param response response from the slave corresponding to request + * @param libRequest modbus request + * @param operationId operation id for logging + * @throws ModbusUnexpectedResponseFunctionCodeException when response function code does not match the request + * (ill-behaving slave) + */ + private void checkFunctionCode(ModbusResponse response, ModbusRequest libRequest, String operationId) + throws ModbusUnexpectedResponseFunctionCodeException { + if ((response.getFunctionCode() != libRequest.getFunctionCode())) { + throw new ModbusUnexpectedResponseFunctionCodeException(libRequest.getTransactionID(), + response.getTransactionID()); + } + } + + /** + * Check that number of bits/registers/discrete inputs is not less than what was requested. + * + * According to modbus protocol, we should get always get always equal amount of registers data back as response. + * With coils and discrete inputs, we can get more since responses are in 8 bit chunks. + * + * However, in no case we expect less items in response. + * + * This is to identify clearly invalid responses which might cause problems downstream when using the data. + * + * @param response response response from the slave corresponding to request + * @param request modbus request + * @param operationId operation id for logging + * @throws ModbusUnexpectedResponseSizeException when data length of the response and request do not match + */ + private void checkResponseSize(ModbusResponse response, ModbusReadRequestBlueprint request, String operationId) + throws ModbusUnexpectedResponseSizeException { + final int responseCount = ModbusLibraryWrapper.getNumberOfItemsInResponse(response, request); + if (responseCount < request.getDataLength()) { + throw new ModbusUnexpectedResponseSizeException(request.getDataLength(), responseCount); + } + } + + /** + * Implementation for the PollTask operation + * + * @author Sami Salonen - Initial contribution + * + */ + private class PollOperation implements ModbusOperation { + @Override + public void accept(AggregateStopWatch timer, PollTask task, ModbusSlaveConnection connection) + throws ModbusException, ModbusUnexpectedTransactionIdException, + ModbusUnexpectedResponseFunctionCodeException, ModbusUnexpectedResponseSizeException { + ModbusSlaveEndpoint endpoint = task.getEndpoint(); + ModbusReadRequestBlueprint request = task.getRequest(); + ModbusReadCallback callback = task.getResultCallback(); + String operationId = timer.operationId; + + ModbusTransaction transaction = ModbusLibraryWrapper.createTransactionForEndpoint(endpoint, connection); + ModbusRequest libRequest = ModbusLibraryWrapper.createRequest(request); + transaction.setRequest(libRequest); + + logger.trace("Going execute transaction with request request (FC={}): {} [operation ID {}]", + request.getFunctionCode(), libRequest.getHexMessage(), operationId); + // Might throw ModbusIOException (I/O error) or ModbusSlaveException (explicit exception response from + // slave) + timer.transaction.timeRunnableWithModbusException(transaction::execute); + ModbusResponse response = transaction.getResponse(); + logger.trace("Response for read request (FC={}, transaction ID={}): {} [operation ID {}]", + response.getFunctionCode(), response.getTransactionID(), response.getHexMessage(), operationId); + checkTransactionId(response, libRequest, operationId); + checkFunctionCode(response, libRequest, operationId); + checkResponseSize(response, request, operationId); + timer.callback + .timeRunnable(() -> ModbusLibraryWrapper.invokeCallbackWithResponse(request, callback, response)); + } + } + + /** + * Implementation for WriteTask operation + * + * @author Sami Salonen - Initial contribution + * + */ + private class WriteOperation implements ModbusOperation { + @Override + public void accept(AggregateStopWatch timer, WriteTask task, ModbusSlaveConnection connection) + throws ModbusException, ModbusUnexpectedTransactionIdException, + ModbusUnexpectedResponseFunctionCodeException { + ModbusSlaveEndpoint endpoint = task.getEndpoint(); + ModbusWriteRequestBlueprint request = task.getRequest(); + @Nullable + ModbusWriteCallback callback = task.getResultCallback(); + String operationId = timer.operationId; + + ModbusTransaction transaction = ModbusLibraryWrapper.createTransactionForEndpoint(endpoint, connection); + ModbusRequest libRequest = ModbusLibraryWrapper.createRequest(request); + transaction.setRequest(libRequest); + + logger.trace("Going execute transaction with read request (FC={}): {} [operation ID {}]", + request.getFunctionCode(), libRequest.getHexMessage(), operationId); + + // Might throw ModbusIOException (I/O error) or ModbusSlaveException (explicit exception response from + // slave) + timer.transaction.timeRunnableWithModbusException(transaction::execute); + ModbusResponse response = transaction.getResponse(); + logger.trace("Response for write request (FC={}, transaction ID={}): {} [operation ID {}]", + response.getFunctionCode(), response.getTransactionID(), response.getHexMessage(), operationId); + checkTransactionId(response, libRequest, operationId); + checkFunctionCode(response, libRequest, operationId); + timer.callback.timeRunnable( + () -> invokeCallbackWithResponse(request, callback, new ModbusResponseImpl(response))); + } + } + + private final Logger logger = LoggerFactory.getLogger(ModbusManagerImpl.class); + private final Logger pollMonitorLogger = LoggerFactory + .getLogger(ModbusManagerImpl.class.getName() + ".PollMonitor"); + + /** + * Time to wait between connection passive+borrow, i.e. time to wait between + * transactions + * Default 60ms for TCP slaves, Siemens S7 1212 PLC couldn't handle faster + * requests with default settings. + */ + public static final long DEFAULT_TCP_INTER_TRANSACTION_DELAY_MILLIS = 60; + + /** + * Time to wait between connection passive+borrow, i.e. time to wait between + * transactions + * Default 35ms for Serial slaves, motivation discussed + * here https://community.openhab.org/t/connection-pooling-in-modbus-binding/5246/111?u=ssalonen + */ + public static final long DEFAULT_SERIAL_INTER_TRANSACTION_DELAY_MILLIS = 35; + + /** + * Default connection timeout + */ + public static final int DEFAULT_CONNECT_TIMEOUT_MILLIS = 10_000; + + /** + * Thread naming for modbus read & write requests. Also used by the monitor thread + */ + private static final String MODBUS_POLLER_THREAD_POOL_NAME = "modbusManagerPollerThreadPool"; + + /** + * Log message with WARN level if the task queues exceed this limit. + * + * If the queues grow too large, it might be an issue with consumer of the ModbusManager. + * + * You can generate large queue by spamming ModbusManager with one-off read or writes (submitOnTimePoll or + * submitOneTimeWrite). + * + * Note that there is no issue registering many regular polls, those do not "queue" the same way. + * + * Presumably slow callbacks can increase queue size with callbackThreadPool + */ + private static final long WARN_QUEUE_SIZE = 500; + private static final long MONITOR_QUEUE_INTERVAL_MILLIS = 10000; + private static final Function DEFAULT_POOL_CONFIGURATION = endpoint -> endpoint + .accept(new ModbusSlaveEndpointVisitor() { + + @Override + public @NonNull EndpointPoolConfiguration visit(ModbusTCPSlaveEndpoint modbusIPSlavePoolingKey) { + EndpointPoolConfiguration endpointPoolConfig = new EndpointPoolConfiguration(); + endpointPoolConfig.setInterTransactionDelayMillis(DEFAULT_TCP_INTER_TRANSACTION_DELAY_MILLIS); + endpointPoolConfig.setConnectMaxTries(Modbus.DEFAULT_RETRIES); + endpointPoolConfig.setConnectTimeoutMillis(DEFAULT_CONNECT_TIMEOUT_MILLIS); + return endpointPoolConfig; + } + + @Override + public @NonNull EndpointPoolConfiguration visit(ModbusSerialSlaveEndpoint modbusSerialSlavePoolingKey) { + EndpointPoolConfiguration endpointPoolConfig = new EndpointPoolConfiguration(); + // never "disconnect" (close/open serial port) serial connection between borrows + endpointPoolConfig.setReconnectAfterMillis(-1); + endpointPoolConfig.setInterTransactionDelayMillis(DEFAULT_SERIAL_INTER_TRANSACTION_DELAY_MILLIS); + endpointPoolConfig.setConnectMaxTries(Modbus.DEFAULT_RETRIES); + endpointPoolConfig.setConnectTimeoutMillis(DEFAULT_CONNECT_TIMEOUT_MILLIS); + return endpointPoolConfig; + } + + @Override + public @NonNull EndpointPoolConfiguration visit(ModbusUDPSlaveEndpoint modbusUDPSlavePoolingKey) { + EndpointPoolConfiguration endpointPoolConfig = new EndpointPoolConfiguration(); + endpointPoolConfig.setInterTransactionDelayMillis(DEFAULT_TCP_INTER_TRANSACTION_DELAY_MILLIS); + endpointPoolConfig.setConnectMaxTries(Modbus.DEFAULT_RETRIES); + endpointPoolConfig.setConnectTimeoutMillis(DEFAULT_CONNECT_TIMEOUT_MILLIS); + return endpointPoolConfig; + } + }); + + private final PollOperation pollOperation = new PollOperation(); + private final WriteOperation writeOperation = new WriteOperation(); + + private volatile long lastQueueMonitorLog = -1; + + /** + * We use connection pool to ensure that only single transaction is ongoing per each endpoint. This is especially + * important with serial slaves but practice has shown that even many tcp slaves have limited + * capability to handle many connections at the same time + * + * Relevant discussion at the time of implementation: + * - https://community.openhab.org/t/modbus-connection-problem/6108/ + * - https://community.openhab.org/t/connection-pooling-in-modbus-binding/5246/ + */ + + private volatile @Nullable KeyedObjectPool connectionPool; + private volatile @Nullable ModbusSlaveConnectionFactoryImpl connectionFactory; + private volatile Map> scheduledPollTasks = new ConcurrentHashMap<>(); + /** + * Executor for requests + */ + private volatile @Nullable ScheduledExecutorService scheduledThreadPoolExecutor; + private volatile @Nullable ScheduledFuture monitorFuture; + private volatile Set communicationInterfaces = ConcurrentHashMap.newKeySet(); + + private void constructConnectionPool() { + ModbusSlaveConnectionFactoryImpl connectionFactory = new ModbusSlaveConnectionFactoryImpl( + DEFAULT_POOL_CONFIGURATION); + GenericKeyedObjectPool genericKeyedObjectPool = new ModbusConnectionPool( + connectionFactory); + genericKeyedObjectPool.setSwallowedExceptionListener(new SwallowedExceptionListener() { + + @SuppressWarnings("null") + @Override + public void onSwallowException(@Nullable Exception e) { + LoggerFactory.getLogger(ModbusManagerImpl.class).warn( + "Connection pool swallowed unexpected exception:{} {}", + Optional.ofNullable(e).map(ex -> ex.getClass().getSimpleName()).orElse(""), + Optional.ofNullable(e).map(ex -> ex.getMessage()).orElse(""), e); + } + }); + connectionPool = genericKeyedObjectPool; + this.connectionFactory = connectionFactory; + } + + private Optional borrowConnection(ModbusSlaveEndpoint endpoint) { + Optional connection = Optional.empty(); + KeyedObjectPool pool = connectionPool; + if (pool == null) { + return connection; + } + long start = System.currentTimeMillis(); + try { + connection = Optional.ofNullable(pool.borrowObject(endpoint)); + } catch (Exception e) { + logger.warn("Error getting a new connection for endpoint {}. Error was: {} {}", endpoint, + e.getClass().getName(), e.getMessage()); + } + if (connection.isPresent()) { + ModbusSlaveConnection slaveConnection = connection.get(); + if (!slaveConnection.isConnected()) { + logger.trace( + "Received connection which is unconnected, preventing use by returning connection to pool."); + returnConnection(endpoint, connection); + connection = Optional.empty(); + } + } + logger.trace("borrowing connection (got {}) for endpoint {} took {} ms", connection, endpoint, + System.currentTimeMillis() - start); + return connection; + } + + private void invalidate(ModbusSlaveEndpoint endpoint, Optional connection) { + KeyedObjectPool pool = connectionPool; + if (pool == null) { + return; + } + long start = System.currentTimeMillis(); + connection.ifPresent(con -> { + try { + pool.invalidateObject(endpoint, con); + } catch (Exception e) { + logger.warn("Error invalidating connection in pool for endpoint {}. Error was: {} {}", endpoint, + e.getClass().getName(), e.getMessage(), e); + } + }); + logger.trace("invalidating connection for endpoint {} took {} ms", endpoint, + System.currentTimeMillis() - start); + } + + private void returnConnection(ModbusSlaveEndpoint endpoint, Optional connection) { + KeyedObjectPool pool = connectionPool; + if (pool == null) { + return; + } + long start = System.currentTimeMillis(); + connection.ifPresent(con -> { + try { + pool.returnObject(endpoint, con); + logger.trace("returned connection to pool for endpoint {}", endpoint); + } catch (Exception e) { + logger.warn("Error returning connection to pool for endpoint {}. Error was: {} {}", endpoint, + e.getClass().getName(), e.getMessage(), e); + } + }); + logger.trace("returning connection for endpoint {} took {} ms", endpoint, System.currentTimeMillis() - start); + } + + /** + * Establishes connection to the endpoint specified by the task + * + * In case connection cannot be established, callback is called with {@link ModbusConnectionException} + * + * @param operationId id appened to log messages for identifying the operation + * @param oneOffTask whether this is one-off, or execution of previously scheduled poll + * @param task task representing the read or write operation + * @return {@link ModbusSlaveConnection} to the endpoint as specified by the task, or empty {@link Optional} when + * connection cannot be established + * @throws PollTaskUnregistered + */ + private , T extends TaskWithEndpoint> Optional getConnection( + AggregateStopWatch timer, boolean oneOffTask, @NonNull T task) throws PollTaskUnregistered { + KeyedObjectPool connectionPool = this.connectionPool; + if (connectionPool == null) { + return Optional.empty(); + } + String operationId = timer.operationId; + logger.trace( + "Executing task {} (oneOff={})! Waiting for connection. Idle connections for this endpoint: {}, and active {} [operation ID {}]", + task, oneOffTask, connectionPool.getNumIdle(task.getEndpoint()), + connectionPool.getNumActive(task.getEndpoint()), operationId); + long connectionBorrowStart = System.currentTimeMillis(); + ModbusFailureCallback failureCallback = task.getFailureCallback(); + ModbusSlaveEndpoint endpoint = task.getEndpoint(); + + R request = task.getRequest(); + Optional connection = timer.connection.timeSupplier(() -> borrowConnection(endpoint)); + logger.trace("Executing task {} (oneOff={})! Connection received in {} ms [operation ID {}]", task, oneOffTask, + System.currentTimeMillis() - connectionBorrowStart, operationId); + if (scheduledThreadPoolExecutor == null) { + // manager deactivated + timer.connection.timeRunnable(() -> invalidate(endpoint, connection)); + return Optional.empty(); + } + if (connection.isEmpty()) { + logger.warn("Could not connect to endpoint {} -- aborting request {} [operation ID {}]", endpoint, request, + operationId); + timer.callback.timeRunnable( + () -> invokeCallbackWithError(request, failureCallback, new ModbusConnectionException(endpoint))); + } + return connection; + } + + private void invokeCallbackWithError(R request, ModbusFailureCallback callback, Exception error) { + try { + logger.trace("Calling error response callback {} for request {}. Error was {} {}", callback, request, + error.getClass().getName(), error.getMessage()); + callback.handle(new AsyncModbusFailure(request, error)); + } finally { + logger.trace("Called write response callback {} for request {}. Error was {} {}", callback, request, + error.getClass().getName(), error.getMessage()); + } + } + + private void invokeCallbackWithResponse(ModbusWriteRequestBlueprint request, ModbusWriteCallback callback, + org.openhab.core.io.transport.sbus.ModbusResponse response) { + try { + logger.trace("Calling write response callback {} for request {}. Response was {}", callback, request, + response); + callback.handle(new AsyncModbusWriteResult(request, response)); + } finally { + logger.trace("Called write response callback {} for request {}. Response was {}", callback, request, + response); + } + } + + private void verifyTaskIsRegistered(PollTask task) throws PollTaskUnregistered { + if (!this.scheduledPollTasks.containsKey(task)) { + String msg = String.format("Poll task %s is unregistered", task); + logger.debug(msg); + throw new PollTaskUnregistered(msg); + } + } + + /** + * Execute operation using a retry mechanism. + * + * This is a helper function for executing read and write operations and handling the exceptions in a common way. + * + * With some connection types, the connection is reseted (disconnected), and new connection is received from the + * pool. This means that potentially other operations queuing for the connection can be executed in-between. + * + * With some other connection types, the operation is retried without resetting the connection type. + * + * @param task + * @param oneOffTask + * @param operation + */ + private , T extends TaskWithEndpoint> void executeOperation( + T task, boolean oneOffTask, ModbusOperation operation) { + AggregateStopWatch timer = new AggregateStopWatch(); + timer.total.resume(); + String operationId = timer.operationId; + + ModbusSlaveConnectionFactoryImpl connectionFactory = this.connectionFactory; + if (connectionFactory == null) { + // deactivated manager + logger.trace("Deactivated manager - aborting operation."); + return; + } + + logTaskQueueInfo(); + R request = task.getRequest(); + ModbusSlaveEndpoint endpoint = task.getEndpoint(); + F failureCallback = task.getFailureCallback(); + int maxTries = task.getMaxTries(); + AtomicReference<@Nullable Exception> lastError = new AtomicReference<>(); + long retryDelay = getEndpointPoolConfiguration(endpoint).getInterTransactionDelayMillis(); + + if (maxTries <= 0) { + throw new IllegalArgumentException("maxTries should be positive"); + } + + Optional connection = Optional.empty(); + try { + logger.trace("Starting new operation with task {}. Trying to get connection [operation ID {}]", task, + operationId); + connection = getConnection(timer, oneOffTask, task); + logger.trace("Operation with task {}. Got a connection {} [operation ID {}]", task, + connection.isPresent() ? "successfully" : "which was unconnected (connection issue)", operationId); + if (connection.isEmpty()) { + // Could not acquire connection, time to abort + // Error logged already, error callback called as well + logger.trace("Initial connection was not successful, aborting. [operation ID {}]", operationId); + return; + } + + if (scheduledThreadPoolExecutor == null) { + logger.debug("Manager has been shut down, aborting proecssing request {} [operation ID {}]", request, + operationId); + return; + } + + int tryIndex = 0; + /** + * last execution is tracked such that the endpoint is not spammed on retry. First retry can be executed + * right away since getConnection ensures enough time has passed since last transaction. More precisely, + * ModbusSlaveConnectionFactoryImpl sleeps on activate() (i.e. before returning connection). + */ + @Nullable + Long lastTryMillis = null; + while (tryIndex < maxTries) { + logger.trace("Try {} out of {} [operation ID {}]", tryIndex + 1, maxTries, operationId); + if (connection.isEmpty()) { + // Connection was likely reseted with previous try, and connection was not successfully + // re-established. Error has been logged, time to abort. + logger.trace("Try {} out of {}. Connection was not successful, aborting. [operation ID {}]", + tryIndex + 1, maxTries, operationId); + return; + } + if (Thread.interrupted()) { + logger.warn("Thread interrupted. Aborting operation [operation ID {}]", operationId); + return; + } + // Check poll task is still registered (this is all asynchronous) + if (!oneOffTask && task instanceof PollTask pollTask) { + verifyTaskIsRegistered(pollTask); + } + // Let's ensure that enough time is between the retries + logger.trace( + "Ensuring that enough time passes before retrying again. Sleeping if necessary [operation ID {}]", + operationId); + long slept = ModbusSlaveConnectionFactoryImpl.waitAtleast(lastTryMillis, retryDelay); + logger.trace("Sleep ended, slept {} [operation ID {}]", slept, operationId); + + boolean willRetry = false; + try { + tryIndex++; + willRetry = tryIndex < maxTries; + operation.accept(timer, task, connection.get()); + lastError.set(null); + break; + } catch (IOException e) { + lastError.set(new ModbusSlaveIOExceptionImpl(e)); + // IO exception occurred, we re-establish new connection hoping it would fix the issue (e.g. + // broken pipe on write) + if (willRetry) { + logger.warn( + "Try {} out of {} failed when executing request ({}). Will try again soon. Error was I/O error, so resetting the connection. Error details: {} {} [operation ID {}]", + tryIndex, maxTries, request, e.getClass().getName(), e.getMessage(), operationId); + } else { + logger.error( + "Last try {} failed when executing request ({}). Aborting. Error was I/O error, so resetting the connection. Error details: {} {} [operation ID {}]", + tryIndex, request, e.getClass().getName(), e.getMessage(), operationId); + } + if (endpoint instanceof ModbusSerialSlaveEndpoint) { + // Workaround for https://github.com/openhab/openhab-core/issues/1842 + // Avoid disconnect/re-connect serial interfaces + logger.debug("Skipping invalidation of serial connection to workaround openhab-core#1842."); + } else { + // Invalidate connection, and empty (so that new connection is acquired before new retry) + timer.connection.timeConsumer(c -> invalidate(endpoint, c), connection); + connection = Optional.empty(); + } + continue; + } catch (ModbusIOException e) { + lastError.set(new ModbusSlaveIOExceptionImpl(e)); + // IO exception occurred, we re-establish new connection hoping it would fix the issue (e.g. + // broken pipe on write) + if (willRetry) { + logger.warn( + "Try {} out of {} failed when executing request ({}). Will try again soon. Error was I/O error, so resetting the connection. Error details: {} {} [operation ID {}]", + tryIndex, maxTries, request, e.getClass().getName(), e.getMessage(), operationId); + } else { + logger.error( + "Last try {} failed when executing request ({}). Aborting. Error was I/O error, so resetting the connection. Error details: {} {} [operation ID {}]", + tryIndex, request, e.getClass().getName(), e.getMessage(), operationId); + } + if (endpoint instanceof ModbusSerialSlaveEndpoint) { + // Workaround for https://github.com/openhab/openhab-core/issues/1842 + // Avoid disconnect/re-connect serial interfaces + logger.debug("Skipping invalidation of serial connection to workaround openhab-core#1842."); + } else { + // Invalidate connection, and empty (so that new connection is acquired before new retry) + timer.connection.timeConsumer(c -> invalidate(endpoint, c), connection); + connection = Optional.empty(); + } + continue; + } catch (ModbusSlaveException e) { + lastError.set(new ModbusSlaveErrorResponseExceptionImpl(e)); + // Slave returned explicit error response, no reason to re-establish new connection + if (willRetry) { + logger.warn( + "Try {} out of {} failed when executing request ({}). Will try again soon. Error was: {} {} [operation ID {}]", + tryIndex, maxTries, request, e.getClass().getName(), e.getMessage(), operationId); + } else { + logger.error( + "Last try {} failed when executing request ({}). Aborting. Error was: {} {} [operation ID {}]", + tryIndex, request, e.getClass().getName(), e.getMessage(), operationId); + } + continue; + } catch (ModbusUnexpectedTransactionIdException | ModbusUnexpectedResponseFunctionCodeException + | ModbusUnexpectedResponseSizeException e) { + lastError.set(e); + // transaction error details already logged + if (willRetry) { + logger.warn( + "Try {} out of {} failed when executing request ({}). Will try again soon. The response did not match the request. Resetting the connection. Error details: {} {} [operation ID {}]", + tryIndex, maxTries, request, e.getClass().getName(), e.getMessage(), operationId); + } else { + logger.error( + "Last try {} failed when executing request ({}). Aborting. The response did not match the request. Resetting the connection. Error details: {} {} [operation ID {}]", + tryIndex, request, e.getClass().getName(), e.getMessage(), operationId); + } + if (endpoint instanceof ModbusSerialSlaveEndpoint) { + // Workaround for https://github.com/openhab/openhab-core/issues/1842 + // Avoid disconnect/re-connect serial interfaces + logger.debug("Skipping invalidation of serial connection to workaround openhab-core#1842."); + } else { + // Invalidate connection, and empty (so that new connection is acquired before new retry) + timer.connection.timeConsumer(c -> invalidate(endpoint, c), connection); + connection = Optional.empty(); + } + continue; + } catch (ModbusException e) { + lastError.set(e); + // Some other (unexpected) exception occurred + if (willRetry) { + logger.warn( + "Try {} out of {} failed when executing request ({}). Will try again soon. Error was unexpected error, so resetting the connection. Error details: {} {} [operation ID {}]", + tryIndex, maxTries, request, e.getClass().getName(), e.getMessage(), operationId, e); + } else { + logger.error( + "Last try {} failed when executing request ({}). Aborting. Error was unexpected error, so resetting the connection. Error details: {} {} [operation ID {}]", + tryIndex, request, e.getClass().getName(), e.getMessage(), operationId, e); + } + // Invalidate connection, and empty (so that new connection is acquired before new retry) + timer.connection.timeConsumer(c -> invalidate(endpoint, c), connection); + connection = Optional.empty(); + continue; + } finally { + lastTryMillis = System.currentTimeMillis(); + // Connection was reseted in error handling and needs to be reconnected. + // Try to re-establish connection. + if (willRetry && connection.isEmpty()) { + connection = getConnection(timer, oneOffTask, task); + } + } + } + Exception exception = lastError.get(); + if (exception != null) { + // All retries failed with some error + timer.callback.timeRunnable(() -> { + invokeCallbackWithError(request, failureCallback, exception); + }); + } + } catch (PollTaskUnregistered e) { + logger.warn("Poll task was unregistered -- not executing/proceeding with the poll: {} [operation ID {}]", + e.getMessage(), operationId); + return; + } catch (InterruptedException e) { + logger.warn("Poll task was canceled -- not executing/proceeding with the poll: {} [operation ID {}]", + e.getMessage(), operationId); + if (endpoint instanceof ModbusSerialSlaveEndpoint) { + // Workaround for https://github.com/openhab/openhab-core/issues/1842 + // Avoid disconnect/re-connect serial interfaces + logger.debug("Skipping invalidation of serial connection to workaround openhab-core#1842."); + } else { + // Invalidate connection, and empty (so that new connection is acquired before new retry) + timer.connection.timeConsumer(c -> invalidate(endpoint, c), connection); + connection = Optional.empty(); + } + } finally { + timer.connection.timeConsumer(c -> returnConnection(endpoint, c), connection); + logger.trace("Connection was returned to the pool, ending operation [operation ID {}]", operationId); + timer.suspendAllRunning(); + logger.debug("Modbus operation ended, timing info: {} [operation ID {}]", timer, operationId); + } + } + + private class ModbusCommunicationInterfaceImpl implements ModbusCommunicationInterface { + + private volatile ModbusSlaveEndpoint endpoint; + private volatile Set pollTasksRegisteredByThisCommInterface = ConcurrentHashMap.newKeySet(); + private volatile boolean closed; + private @Nullable EndpointPoolConfiguration configuration; + + @SuppressWarnings("null") + private ModbusCommunicationInterfaceImpl(ModbusSlaveEndpoint endpoint, + @Nullable EndpointPoolConfiguration configuration) { + this.endpoint = endpoint; + this.configuration = configuration; + connectionFactory.setEndpointPoolConfiguration(endpoint, configuration); + } + + @Override + public Future submitOneTimePoll(ModbusReadRequestBlueprint request, ModbusReadCallback resultCallback, + ModbusFailureCallback failureCallback) { + if (closed) { + throw new IllegalStateException("Communication interface is closed already!"); + } + ScheduledExecutorService executor = scheduledThreadPoolExecutor; + Objects.requireNonNull(executor, "Not activated!"); + long scheduleTime = System.currentTimeMillis(); + BasicPollTask task = new BasicPollTask(endpoint, request, resultCallback, failureCallback); + logger.debug("Scheduling one-off poll task {}", task); + return executor.submit(() -> { + long millisInThreadPoolWaiting = System.currentTimeMillis() - scheduleTime; + logger.debug("Will now execute one-off poll task {}, waited in thread pool for {}", task, + millisInThreadPoolWaiting); + executeOperation(task, true, pollOperation); + }); + } + + @Override + public PollTask registerRegularPoll(ModbusReadRequestBlueprint request, long pollPeriodMillis, + long initialDelayMillis, ModbusReadCallback resultCallback, + ModbusFailureCallback failureCallback) { + synchronized (ModbusManagerImpl.this) { + if (closed) { + throw new IllegalStateException("Communication interface is closed already!"); + } + ScheduledExecutorService executor = scheduledThreadPoolExecutor; + Objects.requireNonNull(executor, "Not activated!"); + BasicPollTask task = new BasicPollTask(endpoint, request, resultCallback, failureCallback); + logger.trace("Registering poll task {} with period {} using initial delay {}", task, pollPeriodMillis, + initialDelayMillis); + if (scheduledPollTasks.containsKey(task)) { + logger.trace("Unregistering previous poll task (possibly with different period)"); + unregisterRegularPoll(task); + } + ScheduledFuture future = executor.scheduleWithFixedDelay(() -> { + long started = System.currentTimeMillis(); + logger.debug("Executing scheduled ({}ms) poll task {}. Current millis: {}", pollPeriodMillis, task, + started); + try { + executeOperation(task, false, pollOperation); + } catch (RuntimeException e) { + // We want to catch all unexpected exceptions since all unhandled exceptions make + // ScheduledExecutorService halt the polling. It is better to print out the exception, and try + // again + // (on next poll cycle) + logger.warn( + "Execution of scheduled ({}ms) poll task {} failed unexpectedly. Ignoring exception, polling again according to poll interval.", + pollPeriodMillis, task, e); + } + long finished = System.currentTimeMillis(); + logger.debug( + "Execution of scheduled ({}ms) poll task {} finished at {}. Was started at millis: {} (=duration of {} millis)", + pollPeriodMillis, task, finished, started, finished - started); + }, initialDelayMillis, pollPeriodMillis, TimeUnit.MILLISECONDS); + + scheduledPollTasks.put(task, future); + pollTasksRegisteredByThisCommInterface.add(task); + logger.trace("Registered poll task {} with period {} using initial delay {}", task, pollPeriodMillis, + initialDelayMillis); + return task; + } + } + + @SuppressWarnings({ "null", "unused" }) + @Override + public boolean unregisterRegularPoll(PollTask task) { + synchronized (ModbusManagerImpl.this) { + if (closed) { + // Closed already, nothing to unregister + return false; + } + pollTasksRegisteredByThisCommInterface.remove(task); + ModbusSlaveConnectionFactoryImpl localConnectionFactory = connectionFactory; + Objects.requireNonNull(localConnectionFactory, "Not activated!"); + + // cancel poller + @Nullable + ScheduledFuture future = scheduledPollTasks.remove(task); + if (future == null) { + // No such poll task + logger.warn("Caller tried to unregister nonexisting poll task {}", task); + return false; + } + logger.debug("Unregistering regular poll task {} (interrupting if necessary)", task); + future.cancel(true); + logger.debug("Poll task {} canceled", task); + return true; + } + } + + @Override + public Future submitOneTimeWrite(ModbusWriteRequestBlueprint request, ModbusWriteCallback resultCallback, + ModbusFailureCallback failureCallback) { + if (closed) { + throw new IllegalStateException("Communication interface is closed already!"); + } + ScheduledExecutorService localScheduledThreadPoolExecutor = scheduledThreadPoolExecutor; + Objects.requireNonNull(localScheduledThreadPoolExecutor, "Not activated!"); + WriteTask task = new BasicWriteTask(endpoint, request, resultCallback, failureCallback); + long scheduleTime = System.currentTimeMillis(); + logger.debug("Scheduling one-off write task {}", task); + return localScheduledThreadPoolExecutor.submit(() -> { + long millisInThreadPoolWaiting = System.currentTimeMillis() - scheduleTime; + logger.debug("Will now execute one-off write task {}, waited in thread pool for {}", task, + millisInThreadPoolWaiting); + executeOperation(task, true, writeOperation); + }); + } + + @Override + public void close() throws Exception { + synchronized (ModbusManagerImpl.this) { + if (closed) { + // Closed already, nothing to unregister + return; + } + // Iterate over all tasks registered by this communication interface, and unregister those + // We copy pollTasksRegisteredByThisCommInterface temporarily so that unregisterRegularPoll can + // remove entries from pollTasksRegisteredByThisCommInterface + Iterable tasksToUnregister = new LinkedList<>(pollTasksRegisteredByThisCommInterface); + for (PollTask task : tasksToUnregister) { + unregisterRegularPoll(task); + } + unregisterCommunicationInterface(this); + closed = true; + } + } + + @Override + public ModbusSlaveEndpoint getEndpoint() { + return endpoint; + } + } + + @Override + public ModbusCommunicationInterface newModbusCommunicationInterface(ModbusSlaveEndpoint endpoint, + @Nullable EndpointPoolConfiguration configuration) throws IllegalArgumentException { + boolean openCommFoundWithSameEndpointDifferentConfig = communicationInterfaces.stream() + .filter(comm -> comm.endpoint.equals(endpoint)) + .anyMatch(comm -> !Optional.ofNullable(comm.configuration) + .orElseGet(() -> DEFAULT_POOL_CONFIGURATION.apply(endpoint)) + .equals(Optional.ofNullable(configuration) + .orElseGet(() -> DEFAULT_POOL_CONFIGURATION.apply(endpoint)))); + if (openCommFoundWithSameEndpointDifferentConfig) { + throw new IllegalArgumentException( + "Communication interface is already open with different configuration to this same endpoint"); + } + + ModbusCommunicationInterfaceImpl comm = new ModbusCommunicationInterfaceImpl(endpoint, configuration); + communicationInterfaces.add(comm); + return comm; + } + + @Override + public EndpointPoolConfiguration getEndpointPoolConfiguration(ModbusSlaveEndpoint endpoint) { + Objects.requireNonNull(connectionFactory, "Not activated!"); + return connectionFactory.getEndpointPoolConfiguration(endpoint); + } + + private void unregisterCommunicationInterface(ModbusCommunicationInterface commInterface) { + communicationInterfaces.remove(commInterface); + maybeCloseConnections(commInterface.getEndpoint()); + } + + private void maybeCloseConnections(ModbusSlaveEndpoint endpoint) { + boolean lastCommWithThisEndpointWasRemoved = communicationInterfaces.stream() + .noneMatch(comm -> comm.endpoint.equals(endpoint)); + if (lastCommWithThisEndpointWasRemoved) { + // Since last communication interface pointing to this endpoint was closed, we can clean up resources + // and disconnect connections. + + // Make sure connections to this endpoint are closed when they are returned to pool (which + // is usually pretty soon as transactions should be relatively short-lived) + ModbusSlaveConnectionFactoryImpl localConnectionFactory = connectionFactory; + if (localConnectionFactory != null) { + localConnectionFactory.disconnectOnReturn(endpoint, System.currentTimeMillis()); + try { + // Close all idle connections as well (they will be reconnected if necessary on borrow) + if (connectionPool != null) { + connectionPool.clear(endpoint); + } + } catch (Exception e) { + logger.warn("Could not clear endpoint {}. Stack trace follows", endpoint, e); + } + } + } + } + + @Activate + protected void activate(Map configProperties) { + synchronized (this) { + logger.info("Modbus manager activated"); + if (connectionPool == null) { + constructConnectionPool(); + } + ScheduledExecutorService scheduledThreadPoolExecutor = this.scheduledThreadPoolExecutor; + if (scheduledThreadPoolExecutor == null) { + this.scheduledThreadPoolExecutor = scheduledThreadPoolExecutor = ThreadPoolManager + .getScheduledPool(MODBUS_POLLER_THREAD_POOL_NAME); + } + if (scheduledThreadPoolExecutor.isShutdown()) { + logger.warn("Thread pool is shut down! Aborting activation of ModbusMangerImpl"); + throw new IllegalStateException("Thread pool(s) shut down! Aborting activation of ModbusMangerImpl"); + } + monitorFuture = scheduledThreadPoolExecutor.scheduleWithFixedDelay(this::logTaskQueueInfo, 0, + MONITOR_QUEUE_INTERVAL_MILLIS, TimeUnit.MILLISECONDS); + } + } + + @Deactivate + protected void deactivate() { + synchronized (this) { + KeyedObjectPool connectionPool = this.connectionPool; + if (connectionPool != null) { + for (ModbusCommunicationInterface commInterface : this.communicationInterfaces) { + try { + commInterface.close(); + } catch (Exception e) { + logger.warn("Error when closing communication interface", e); + } + } + + connectionPool.close(); + this.connectionPool = connectionPool = null; + } + + if (monitorFuture != null) { + monitorFuture.cancel(true); + monitorFuture = null; + } + // Note that it is not allowed to shutdown the executor, since they will be reused when + // when pool is received from ThreadPoolManager is called + scheduledThreadPoolExecutor = null; + connectionFactory = null; + logger.debug("Modbus manager deactivated"); + } + } + + private void logTaskQueueInfo() { + synchronized (pollMonitorLogger) { + ScheduledExecutorService scheduledThreadPoolExecutor = this.scheduledThreadPoolExecutor; + if (scheduledThreadPoolExecutor == null) { + return; + } + // Avoid excessive spamming with queue monitor when many tasks are executed + if (System.currentTimeMillis() - lastQueueMonitorLog < MONITOR_QUEUE_INTERVAL_MILLIS) { + return; + } + lastQueueMonitorLog = System.currentTimeMillis(); + pollMonitorLogger.trace(""); + this.scheduledPollTasks.forEach((task, future) -> { + pollMonitorLogger.trace( + "POLL MONITOR: scheduled poll task. FC: {}, start {}, length {}, done: {}, canceled: {}, delay: {}. Full task {}", + task.getRequest().getFunctionCode(), task.getRequest().getReference(), + task.getRequest().getDataLength(), future.isDone(), future.isCancelled(), + future.getDelay(TimeUnit.MILLISECONDS), task); + }); + if (scheduledThreadPoolExecutor instanceof ThreadPoolExecutor executor) { + pollMonitorLogger.trace( + "POLL MONITOR: scheduledThreadPoolExecutor queue size: {}, remaining space {}. Active threads {}", + executor.getQueue().size(), executor.getQueue().remainingCapacity(), executor.getActiveCount()); + if (executor.getQueue().size() >= WARN_QUEUE_SIZE) { + pollMonitorLogger.warn( + "Many ({}) tasks queued in scheduledThreadPoolExecutor! This might be sign of bad design or bug in the binding code.", + executor.getQueue().size()); + } + } + + pollMonitorLogger.trace(""); + } + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/ModbusPoolConfig.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/ModbusPoolConfig.java new file mode 100644 index 00000000000..446ea11f89d --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/ModbusPoolConfig.java @@ -0,0 +1,82 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.internal; + +import org.apache.commons.pool2.impl.DefaultEvictionPolicy; +import org.apache.commons.pool2.impl.EvictionPolicy; +import org.apache.commons.pool2.impl.GenericKeyedObjectPoolConfig; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.io.transport.sbus.internal.pooling.ModbusSlaveConnectionEvictionPolicy; + +import ro.ciprianpascu.sbus.net.ModbusSlaveConnection; + +/** + * Configuration for Modbus connection pool + * + * Default is that + * - there is only one connection per endpoint + * - clients are served "fairly" (first-come-first-serve) + * + * @author Sami Salonen - Initial contribution + * + */ +@NonNullByDefault +public class ModbusPoolConfig extends GenericKeyedObjectPoolConfig<@Nullable ModbusSlaveConnection> { + + @SuppressWarnings("unused") + private EvictionPolicy evictionPolicy = new DefaultEvictionPolicy<>(); + + public ModbusPoolConfig() { + // When the pool is exhausted, multiple calling threads may be simultaneously blocked waiting for instances + // to + // become available. As of pool 1.5, a "fairness" algorithm has been implemented to ensure that threads + // receive + // available instances in request arrival order. + setFairness(true); + + // Limit one connection per endpoint (i.e. same ip:port pair or same serial device). + // If there are multiple read/write requests to process at the same time, block until previous one finishes + setBlockWhenExhausted(true); + setMaxTotalPerKey(1); + + // block infinitely when exhausted + setMaxWaitMillis(-1); + + // Connections are "tested" on return. Effectively, disconnected connections are destroyed when returning on + // pool + // Note that we do not test on borrow -- that would mean blocking situation when connection cannot be + // established. + // Instead, borrowing connection from pool can return unconnected connection. + setTestOnReturn(true); + + // disable JMX + setJmxEnabled(false); + + // Evict idle connections every 10 seconds + setEvictionPolicy(new ModbusSlaveConnectionEvictionPolicy()); + setTimeBetweenEvictionRunsMillis(10000); + // Let eviction re-create ready-to-use idle (=unconnected) connections + // This is to avoid occasional / rare deadlocks seen with pool 2.8.1 & 2.4.3 when + // borrow hangs (waiting indefinitely for idle object to appear in the pool) + // https://github.com/openhab/openhab-addons/issues/8460 + setMinIdlePerKey(1); + } + + @Override + public void setEvictionPolicyClassName(@Nullable String evictionPolicyClassName) { + // Protect against https://issues.apache.org/jira/browse/POOL-338 + // Disallow re-setting eviction policy with class name. Only setEvictionPolicy allowed + throw new IllegalStateException("setEvictionPolicyClassName disallowed! Will fail in OSGI"); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/ModbusResponseImpl.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/ModbusResponseImpl.java new file mode 100644 index 00000000000..6508af9ba07 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/ModbusResponseImpl.java @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.io.transport.sbus.ModbusResponse; + +import ro.ciprianpascu.sbus.msg.ModbusMessage; + +/** + * Basic implementation of {@link ModbusResponse} + * + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public class ModbusResponseImpl implements ModbusResponse { + + private int responseFunctionCode; + + public ModbusResponseImpl(ModbusMessage response) { + super(); + this.responseFunctionCode = response.getFunctionCode(); + } + + @Override + public int getFunctionCode() { + return responseFunctionCode; + } + + @Override + public String toString() { + return String.format("ModbusResponseImpl(responseFC=%d)", responseFunctionCode); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/ModbusSlaveErrorResponseExceptionImpl.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/ModbusSlaveErrorResponseExceptionImpl.java new file mode 100644 index 00000000000..bc2f9ff3907 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/ModbusSlaveErrorResponseExceptionImpl.java @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.internal; + +import java.util.Optional; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.io.transport.sbus.exception.ModbusSlaveErrorResponseException; + +import ro.ciprianpascu.sbus.ModbusSlaveException; + +/** + * Exception for explicit exception responses from Modbus slave + * + * @author Sami Salonen - Initial contribution + * @author Nagy Attila Gabor - added getter for error type + * + */ +@NonNullByDefault +public class ModbusSlaveErrorResponseExceptionImpl extends ModbusSlaveErrorResponseException { + + private static final long serialVersionUID = 6334580162425192133L; + private int rawCode; + private Optional exceptionCode; + + public ModbusSlaveErrorResponseExceptionImpl(ModbusSlaveException e) { + rawCode = e.getType(); + exceptionCode = KnownExceptionCode.tryFromExceptionCode(rawCode); + } + + /** + * @return the Modbus exception code that happened + */ + @Override + public int getExceptionCode() { + return rawCode; + } + + @Override + public @Nullable String getMessage() { + return String.format("Slave responded with error=%d (%s)", rawCode, + exceptionCode.map(Enum::name).orElse("unknown error code")); + } + + @Override + public String toString() { + return String.format("ModbusSlaveErrorResponseException(error=%d)", rawCode); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/ModbusSlaveIOExceptionImpl.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/ModbusSlaveIOExceptionImpl.java new file mode 100644 index 00000000000..73ff0245a48 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/ModbusSlaveIOExceptionImpl.java @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.internal; + +import java.io.IOException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.io.transport.sbus.exception.ModbusSlaveIOException; + +import ro.ciprianpascu.sbus.ModbusIOException; + +/** + * Exception for all IO errors + * + * @author Sami Salonen - Initial contribution + * + */ +@NonNullByDefault +public class ModbusSlaveIOExceptionImpl extends ModbusSlaveIOException { + + private static final long serialVersionUID = -8910463902857643468L; + private Exception error; + + public ModbusSlaveIOExceptionImpl(ModbusIOException e) { + this.error = e; + } + + public ModbusSlaveIOExceptionImpl(IOException e) { + this.error = e; + } + + @Override + public @Nullable String getMessage() { + return String.format("Modbus IO Error with cause=%s, EOF=%s, message='%s', cause2=%s", + error.getClass().getSimpleName(), error instanceof ModbusIOException mioe ? mioe.isEOF() : "?", + error.getMessage(), error.getCause()); + } + + @Override + public String toString() { + return String.format("ModbusSlaveIOException(cause=%s, EOF=%s, message='%s', cause2=%s)", + error.getClass().getSimpleName(), error instanceof ModbusIOException mioe ? mioe.isEOF() : "?", + error.getMessage(), error.getCause()); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/SimpleStopWatch.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/SimpleStopWatch.java new file mode 100644 index 00000000000..fd62197c1c9 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/SimpleStopWatch.java @@ -0,0 +1,174 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.internal; + +import java.util.function.Consumer; +import java.util.function.Supplier; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.io.transport.sbus.internal.ModbusManagerImpl.PollTaskUnregistered; + +import ro.ciprianpascu.sbus.ModbusException; + +/** + * Implementation of simple stop watch. + * + * @author Sami Salonen - initial contribution + * + */ +@NonNullByDefault +public class SimpleStopWatch { + + private volatile long totalMillis; + private volatile long resumed; + + @FunctionalInterface + public abstract interface SupplierWithPollTaskUnregisteredException { + public abstract T get() throws ModbusManagerImpl.PollTaskUnregistered; + } + + @FunctionalInterface + public abstract interface RunnableWithModbusException { + public abstract void run() throws ModbusException; + } + + /** + * Resume or start the stop watch + * + * @throws IllegalStateException if stop watch is running already + */ + public synchronized void resume() { + if (isRunning()) { + throw new IllegalStateException("Cannot suspend a running StopWatch"); + } + resumed = System.currentTimeMillis(); + } + + /** + * Suspend the stop watch + * + * @throws IllegalStateException if stop watch has not been resumed + */ + public synchronized void suspend() { + if (!isRunning()) { + throw new IllegalStateException("Cannot suspend non-running StopWatch"); + } + totalMillis += System.currentTimeMillis() - resumed; + resumed = 0; + } + + /** + * Get total running time of this StopWatch in milliseconds + * + * @return total running time in milliseconds + */ + public synchronized long getTotalTimeMillis() { + return totalMillis; + } + + /** + * Tells whether this StopWatch is now running + * + * @return boolean telling whether this StopWatch is running + */ + public synchronized boolean isRunning() { + return resumed > 0; + } + + /** + * Time single action using this StopWatch + * + * First StopWatch is resumed, then action is applied. Finally the StopWatch is suspended. + * + * @param supplier action to time + * @return return value from supplier + * @throws PollTaskUnregistered when original supplier throws the exception + */ + public R timeSupplierWithPollTaskUnregisteredException(SupplierWithPollTaskUnregisteredException supplier) + throws PollTaskUnregistered { + try { + this.resume(); + return supplier.get(); + } finally { + this.suspend(); + } + } + + /** + * Time single action using this StopWatch + * + * First StopWatch is resumed, then action is applied. Finally the StopWatch is suspended. + * + * @param supplier action to time + * @return return value from supplier + */ + public R timeSupplier(Supplier supplier) { + try { + this.resume(); + return supplier.get(); + } finally { + this.suspend(); + } + } + + /** + * Time single action using this StopWatch + * + * First StopWatch is resumed, then action is applied. Finally the StopWatch is suspended. + * + * @param action action to time + * @throws ModbusException when original action throws the exception + */ + public void timeRunnableWithModbusException(RunnableWithModbusException action) throws ModbusException { + try { + this.resume(); + action.run(); + } finally { + this.suspend(); + } + } + + /** + * Time single action using this StopWatch + * + * First StopWatch is resumed, then action is applied. Finally the StopWatch is suspended. + * + * @param supplier action to time + * @return return value from supplier + */ + public void timeRunnable(Runnable runnable) { + try { + this.resume(); + runnable.run(); + } finally { + this.suspend(); + } + } + + /** + * Time single action using this StopWatch + * + * First StopWatch is resumed, then action is applied. Finally the StopWatch is suspended. + * + * @param consumer action to time + * @return return value from supplier + */ + public void timeConsumer(Consumer consumer, T parameter) { + try { + this.resume(); + consumer.accept(parameter); + } finally { + this.suspend(); + } + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/pooling/ModbusSlaveConnectionEvictionPolicy.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/pooling/ModbusSlaveConnectionEvictionPolicy.java new file mode 100644 index 00000000000..6cb076949f3 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/pooling/ModbusSlaveConnectionEvictionPolicy.java @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.internal.pooling; + +import org.apache.commons.pool2.PooledObject; +import org.apache.commons.pool2.impl.EvictionConfig; +import org.apache.commons.pool2.impl.EvictionPolicy; +import org.openhab.core.io.transport.sbus.internal.pooling.ModbusSlaveConnectionFactoryImpl.PooledConnection; + +import ro.ciprianpascu.sbus.net.ModbusSlaveConnection; + +/** + * Eviction policy, i.e. policy for deciding when to close idle, unused connections. + * + * Connections are evicted according to {@link PooledConnection} maybeResetConnection method. + * + * @author Sami Salonen - Initial contribution + */ +public class ModbusSlaveConnectionEvictionPolicy implements EvictionPolicy { + + @Override + public boolean evict(EvictionConfig config, PooledObject underTest, int idleCount) { + return ((PooledConnection) underTest).maybeResetConnection("evict"); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/pooling/ModbusSlaveConnectionFactory.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/pooling/ModbusSlaveConnectionFactory.java new file mode 100644 index 00000000000..d5ca715e9a2 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/pooling/ModbusSlaveConnectionFactory.java @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.internal.pooling; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.io.transport.sbus.endpoint.ModbusSlaveEndpointVisitor; + +import ro.ciprianpascu.sbus.net.ModbusSlaveConnection; + +/** + * Factory for ModbusSlaveConnection objects using endpoint definition. + * + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public interface ModbusSlaveConnectionFactory extends ModbusSlaveEndpointVisitor { + +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/pooling/ModbusSlaveConnectionFactoryImpl.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/pooling/ModbusSlaveConnectionFactoryImpl.java new file mode 100644 index 00000000000..7f6f03581e1 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/internal/pooling/ModbusSlaveConnectionFactoryImpl.java @@ -0,0 +1,370 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.internal.pooling; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +import org.apache.commons.pool2.BaseKeyedPooledObjectFactory; +import org.apache.commons.pool2.PooledObject; +import org.apache.commons.pool2.impl.DefaultPooledObject; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.io.transport.sbus.endpoint.EndpointPoolConfiguration; +import org.openhab.core.io.transport.sbus.endpoint.ModbusIPSlaveEndpoint; +import org.openhab.core.io.transport.sbus.endpoint.ModbusSerialSlaveEndpoint; +import org.openhab.core.io.transport.sbus.endpoint.ModbusSlaveEndpoint; +import org.openhab.core.io.transport.sbus.endpoint.ModbusSlaveEndpointVisitor; +import org.openhab.core.io.transport.sbus.endpoint.ModbusTCPSlaveEndpoint; +import org.openhab.core.io.transport.sbus.endpoint.ModbusUDPSlaveEndpoint; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ro.ciprianpascu.sbus.net.ModbusSlaveConnection; +import ro.ciprianpascu.sbus.net.SerialConnection; +import ro.ciprianpascu.sbus.net.TCPMasterConnection; +import ro.ciprianpascu.sbus.net.UDPMasterConnection; + +/** + * ModbusSlaveConnectionFactoryImpl responsible of the lifecycle of modbus slave connections + * + * The actual pool uses instance of this class to create and destroy connections as-needed. + * + * The overall functionality goes as follow + * - create: create connection object but do not connect it yet + * - destroyObject: close connection and free all resources. Called by the pool when the pool is being closed or the + * object is invalidated. + * - activateObject: prepare connection to be used. In practice, connect if disconnected + * - passivateObject: passivate connection before returning it back to the pool. Currently, passivateObject closes all + * IP-based connections every now and then (reconnectAfterMillis). Serial connections we keep open. + * - wrap: wrap created connection to pooled object wrapper class. It tracks usage statistics and last connection time. + * + * Note that the implementation must be thread safe. + * + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public class ModbusSlaveConnectionFactoryImpl + extends BaseKeyedPooledObjectFactory { + + class PooledConnection extends DefaultPooledObject<@Nullable ModbusSlaveConnection> { + + private volatile long lastConnected; + private volatile @Nullable ModbusSlaveEndpoint endpoint; + + public PooledConnection(@Nullable ModbusSlaveConnection object) { + super(object); + } + + public long getLastConnected() { + return lastConnected; + } + + public void setLastConnected(ModbusSlaveEndpoint endpoint, long lastConnected) { + this.endpoint = endpoint; + this.lastConnected = lastConnected; + } + + /** + * + * Reset connection if it is too old or fulfills some of the other criteria + * + * @param activityName ongoing activity calling this method. For logging + * @return whether connection was reseted + */ + public boolean maybeResetConnection(String activityName) { + ModbusSlaveEndpoint localEndpoint = endpoint; + if (localEndpoint == null) { + // We have not connected yet, abort + // Without endpoint we have no age parameters available (endpointPoolConfigs & + // disconnectIfConnectedBefore) + return false; + } + long localLastConnected = lastConnected; + + ModbusSlaveConnection connection = getObject(); + if (connection == null) { + return false; + } + + EndpointPoolConfiguration configuration = getEndpointPoolConfiguration(localEndpoint); + long reconnectAfterMillis = configuration.getReconnectAfterMillis(); + long connectionAgeMillis = System.currentTimeMillis() - localLastConnected; + long disconnectIfConnectedBeforeMillis = disconnectIfConnectedBefore.getOrDefault(localEndpoint, -1L); + boolean disconnectSinceTooOldConnection = disconnectIfConnectedBeforeMillis >= 0L + && localLastConnected <= disconnectIfConnectedBeforeMillis; + boolean shouldBeDisconnected = (reconnectAfterMillis == 0 + || (reconnectAfterMillis > 0 && connectionAgeMillis > reconnectAfterMillis) + || disconnectSinceTooOldConnection); + if (shouldBeDisconnected) { + logger.trace( + "({}) Connection {} (endpoint {}) age {}ms is over the reconnectAfterMillis={}ms limit or has been connection time ({}) is after the \"disconnectBeforeConnectedMillis\"={} -> disconnecting.", + activityName, connection, localEndpoint, connectionAgeMillis, reconnectAfterMillis, + localLastConnected, disconnectIfConnectedBeforeMillis); + connection.resetConnection(); + return true; + } else { + logger.trace( + "({}) Connection {} (endpoint {}) age ({}ms) is below the reconnectAfterMillis ({}ms) limit and connection time ({}) is after the \"disconnectBeforeConnectedMillis\"={}. Keep the connection open.", + activityName, connection, localEndpoint, connectionAgeMillis, reconnectAfterMillis, + localLastConnected, disconnectIfConnectedBeforeMillis); + return false; + } + } + } + + private final Logger logger = LoggerFactory.getLogger(ModbusSlaveConnectionFactoryImpl.class); + private volatile Map endpointPoolConfigs = new ConcurrentHashMap<>(); + private volatile Map lastPassivateMillis = new ConcurrentHashMap<>(); + private volatile Map lastConnectMillis = new ConcurrentHashMap<>(); + private volatile Map disconnectIfConnectedBefore = new ConcurrentHashMap<>(); + private final Function defaultPoolConfigurationFactory; + + public ModbusSlaveConnectionFactoryImpl( + Function defaultPoolConfigurationFactory) { + this.defaultPoolConfigurationFactory = defaultPoolConfigurationFactory; + } + + private @Nullable InetAddress getInetAddress(ModbusIPSlaveEndpoint key) { + try { + return InetAddress.getByName(key.getAddress()); + } catch (UnknownHostException e) { + logger.warn("KeyedPooledModbusSlaveConnectionFactory: Unknown host: {}. Connection creation failed.", + e.getMessage()); + return null; + } + } + + @Override + public @Nullable ModbusSlaveConnection create(ModbusSlaveEndpoint endpoint) throws Exception { + return endpoint.accept(new ModbusSlaveEndpointVisitor<@Nullable ModbusSlaveConnection>() { + @Override + public @Nullable ModbusSlaveConnection visit(ModbusSerialSlaveEndpoint modbusSerialSlavePoolingKey) { + SerialConnection connection = new SerialConnection(modbusSerialSlavePoolingKey.getSerialParameters()); + logger.trace("Created connection {} for endpoint {}", connection, modbusSerialSlavePoolingKey); + return connection; + } + + @Override + public @Nullable ModbusSlaveConnection visit(ModbusTCPSlaveEndpoint key) { + InetAddress address = getInetAddress(key); + if (address == null) { + return null; + } + int connectTimeoutMillis = getEndpointPoolConfiguration(key).getConnectTimeoutMillis(); + TCPMasterConnection connection = new TCPMasterConnection(address, key.getPort(), connectTimeoutMillis, + key.getRtuEncoded()); + logger.trace("Created connection {} for endpoint {}", connection, key); + return connection; + } + + @Override + public @Nullable ModbusSlaveConnection visit(ModbusUDPSlaveEndpoint key) { + InetAddress address = getInetAddress(key); + if (address == null) { + return null; + } + UDPMasterConnection connection = new UDPMasterConnection(address, key.getPort()); + logger.trace("Created connection {} for endpoint {}", connection, key); + return connection; + } + }); + } + + @Override + public PooledObject<@Nullable ModbusSlaveConnection> wrap(@Nullable ModbusSlaveConnection connection) { + return new PooledConnection(connection); + } + + @Override + public void destroyObject(ModbusSlaveEndpoint endpoint, + @Nullable PooledObject<@Nullable ModbusSlaveConnection> obj) { + if (obj == null) { + return; + } + ModbusSlaveConnection connection = obj.getObject(); + if (connection == null) { + return; + } + logger.trace("destroyObject for connection {} and endpoint {} -> closing the connection", connection, endpoint); + connection.resetConnection(); + } + + @Override + public void activateObject(ModbusSlaveEndpoint endpoint, + @Nullable PooledObject<@Nullable ModbusSlaveConnection> obj) throws Exception { + if (obj == null) { + return; + } + ModbusSlaveConnection connection = obj.getObject(); + if (connection == null) { + return; + } + try { + EndpointPoolConfiguration config = getEndpointPoolConfiguration(endpoint); + if (!connection.isConnected()) { + tryConnect(endpoint, obj, connection, config); + } + + long waited = waitAtleast(lastPassivateMillis.get(endpoint), config.getInterTransactionDelayMillis()); + logger.trace( + "Waited {}ms (interTransactionDelayMillis {}ms) before giving returning connection {} for endpoint {}, to ensure delay between transactions.", + waited, config.getInterTransactionDelayMillis(), obj.getObject(), endpoint); + } catch (InterruptedException e) { + // Someone wants to cancel us, reset the connection and abort + if (connection.isConnected()) { + connection.resetConnection(); + } + } catch (Exception e) { + logger.warn("Error connecting connection {} for endpoint {}: {}", obj.getObject(), endpoint, + e.getMessage()); + } + } + + @Override + public void passivateObject(ModbusSlaveEndpoint endpoint, + @Nullable PooledObject<@Nullable ModbusSlaveConnection> obj) { + if (obj == null) { + return; + } + ModbusSlaveConnection connection = obj.getObject(); + logger.trace("Passivating connection {} for endpoint {}...", connection, endpoint); + lastPassivateMillis.put(endpoint, System.currentTimeMillis()); + ((PooledConnection) obj).maybeResetConnection("passivate"); + logger.trace("...Passivated connection {} for endpoint {}", obj.getObject(), endpoint); + } + + @Override + public boolean validateObject(ModbusSlaveEndpoint key, @Nullable PooledObject<@Nullable ModbusSlaveConnection> p) { + @SuppressWarnings("null") // p.getObject() cannot be null due to short-circuiting boolean condition + boolean valid = p != null && p.getObject() != null && p.getObject().isConnected(); + ModbusSlaveConnection slaveConnection = p != null ? p.getObject() : null; + logger.trace("Validating endpoint {} connection {} -> {}", key, slaveConnection, valid); + return valid; + } + + /** + * Configure general connection settings with a given endpoint + * + * @param endpoint endpoint to configure + * @param config configuration for the endpoint. Use null to reset the configuration to default settings. + */ + public void setEndpointPoolConfiguration(ModbusSlaveEndpoint endpoint, @Nullable EndpointPoolConfiguration config) { + if (config == null) { + endpointPoolConfigs.remove(endpoint); + } else { + endpointPoolConfigs.put(endpoint, config); + } + } + + /** + * Get general configuration settings applied to a given endpoint + * + * Note that default configuration settings are returned in case the endpoint has not been configured. + * + * @param endpoint endpoint to query + * @return general connection settings of the given endpoint + */ + public EndpointPoolConfiguration getEndpointPoolConfiguration(ModbusSlaveEndpoint endpoint) { + return Optional.ofNullable(endpointPoolConfigs.get(endpoint)) + .orElseGet(() -> defaultPoolConfigurationFactory.apply(endpoint)); + } + + private void tryConnect(ModbusSlaveEndpoint endpoint, PooledObject<@Nullable ModbusSlaveConnection> obj, + ModbusSlaveConnection connection, EndpointPoolConfiguration config) throws Exception { + if (connection.isConnected()) { + return; + } + int tryIndex = 0; + Long lastConnect = lastConnectMillis.get(endpoint); + int maxTries = config.getConnectMaxTries(); + do { + try { + long waited = waitAtleast(lastConnect, + Math.max(config.getInterConnectDelayMillis(), config.getInterTransactionDelayMillis())); + if (waited > 0) { + logger.trace( + "Waited {}ms (interConnectDelayMillis {}ms, interTransactionDelayMillis {}ms) before " + + "connecting disconnected connection {} for endpoint {}, to allow delay " + + "between connections re-connects", + waited, config.getInterConnectDelayMillis(), config.getInterTransactionDelayMillis(), + obj.getObject(), endpoint); + } + connection.connect(); + + logger.trace("Waiting {}ms for connection to warm up...", config.getAfterConnectionDelayMillis()); + if (config.getAfterConnectionDelayMillis() > 0) { + try { + Thread.sleep(config.getAfterConnectionDelayMillis()); + } catch (InterruptedException e) { + // sleep interrupted, continue with the method execution (only fast operations) + } + } + long curTime = System.currentTimeMillis(); + ((PooledConnection) obj).setLastConnected(endpoint, curTime); + lastConnectMillis.put(endpoint, curTime); + break; + } catch (InterruptedException e) { + logger.info("Connect try {}/{} failed: {}. Aborting since interrupted. Connection {}. Endpoint {}.", + tryIndex, maxTries, e.getMessage(), connection, endpoint); + throw e; + } catch (Exception e) { + tryIndex++; + logger.debug("Connect try {}/{} failed: {}. Connection {}. Endpoint {}", tryIndex, maxTries, + e.getMessage(), connection, endpoint); + if (tryIndex >= maxTries) { + logger.warn("Connect reached max tries {}, throwing last error: {}. Connection {}. Endpoint {}", + maxTries, e.getMessage(), connection, endpoint); + throw e; + } + lastConnect = System.currentTimeMillis(); + } + } while (true); + } + + /** + * Sleep until waitMillis has passed from lastOperation + * + * @param lastOperation last time operation was executed, or null if it has not been executed + * @param waitMillis + * @return milliseconds slept + * @throws InterruptedException + */ + public static long waitAtleast(@Nullable Long lastOperation, long waitMillis) throws InterruptedException { + if (lastOperation == null) { + return 0; + } + long millisSinceLast = System.currentTimeMillis() - lastOperation; + long millisToWaitStill = Math.min(waitMillis, Math.max(0, waitMillis - millisSinceLast)); + try { + Thread.sleep(millisToWaitStill); + } catch (InterruptedException e) { + LoggerFactory.getLogger(ModbusSlaveConnectionFactoryImpl.class).debug("wait interrupted", e); + throw e; + } + return millisToWaitStill; + } + + /** + * Disconnect returning connections which have been connected before certain time + * + * @param disconnectBeforeConnectedMillis disconnected connections that have been connected before this time + */ + public void disconnectOnReturn(ModbusSlaveEndpoint endpoint, long disconnectBeforeConnectedMillis) { + disconnectIfConnectedBefore.put(endpoint, disconnectBeforeConnectedMillis); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/json/WriteRequestJsonUtilities.java b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/json/WriteRequestJsonUtilities.java new file mode 100644 index 00000000000..f46620fbcee --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/main/java/org/openhab/core/io/transport/sbus/json/WriteRequestJsonUtilities.java @@ -0,0 +1,216 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.json; + +import java.util.Collection; +import java.util.Deque; +import java.util.LinkedList; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.io.transport.sbus.BitArray; +import org.openhab.core.io.transport.sbus.ModbusConstants; +import org.openhab.core.io.transport.sbus.ModbusRegisterArray; +import org.openhab.core.io.transport.sbus.ModbusWriteCoilRequestBlueprint; +import org.openhab.core.io.transport.sbus.ModbusWriteFunctionCode; +import org.openhab.core.io.transport.sbus.ModbusWriteRegisterRequestBlueprint; +import org.openhab.core.io.transport.sbus.ModbusWriteRequestBlueprint; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +/** + * Utilities for converting JSON to {@link ModbusWriteRequestBlueprint} + * + * + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public final class WriteRequestJsonUtilities { + /** + * Constant for the function code key in the JSON + */ + public static final String JSON_FUNCTION_CODE = "functionCode"; + /** + * Constant for the write address key in the JSON + */ + public static final String JSON_ADDRESS = "address"; + /** + * Constant for the value key in the JSON + */ + public static final String JSON_VALUE = "value"; + /** + * Constant for the maxTries key in the JSON + */ + public static final String JSON_MAX_TRIES = "maxTries"; + + /** + * Default maxTries when it has not been specified + */ + public static final int DEFAULT_MAX_TRIES = 3; + + private WriteRequestJsonUtilities() { + throw new UnsupportedOperationException(); + } + + /** + * Parse JSON string to collection of {@link ModbusWriteRequestBlueprint} + * + * JSON string should represent a JSON array, with JSON objects. Each JSON object represents a write request. The + * JSON object must have the following keys + * - functionCode: numeric function code + * - address: reference or start address of the write + * - value: array of data to be written. Use zero and one when writing coils. With registers, each number + * corresponds to register's 16 bit data. + * - maxTries: number of tries with the write in case of errors + * + * + * @param unitId unit id for the constructed {@link ModbusWriteRequestBlueprint} + * @param jsonString json to be parsed in string format + * @return collection of {@link ModbusWriteRequestBlueprint} representing the json + * @throws IllegalArgumentException in case of unexpected function codes, or too large payload exceeding modbus + * protocol specification + * @throws IllegalStateException in case of parsing errors and unexpected json structure + * + * @see WriteRequestJsonUtilities#JSON_FUNCTION_CODE + * @see WriteRequestJsonUtilities#JSON_ADDRESS + * @see WriteRequestJsonUtilities#JSON_VALUE + * @see WriteRequestJsonUtilities#JSON_MAX_TRIES + */ + public static Collection fromJson(int subnetId, int unitId, String jsonString) { + JsonArray jsonArray = JsonParser.parseString(jsonString).getAsJsonArray(); + if (jsonArray.size() == 0) { + return new LinkedList<>(); + } + Deque writes = new LinkedList<>(); + jsonArray.forEach(writeElem -> { + writes.add(constructBluerint(subnetId, unitId, writeElem)); + }); + return writes; + } + + private static ModbusWriteRequestBlueprint constructBluerint(int subnetId, int unitId, JsonElement arrayElement) { + final JsonObject writeObject; + try { + writeObject = arrayElement.getAsJsonObject(); + } catch (IllegalStateException e) { + throw new IllegalStateException("JSON array contained something else than a JSON object!", e); + } + @Nullable + JsonElement functionCode = writeObject.get(JSON_FUNCTION_CODE); + @Nullable + JsonElement address = writeObject.get(JSON_ADDRESS); + @Nullable + JsonElement maxTries = writeObject.get(JSON_MAX_TRIES); + @Nullable + JsonArray valuesElem; + + try { + valuesElem = writeObject.get(JSON_VALUE).getAsJsonArray(); + } catch (IllegalStateException e) { + throw new IllegalStateException(String.format("JSON object '%s' is not an JSON array!", JSON_VALUE), e); + } + return constructBluerint(subnetId, unitId, functionCode, address, maxTries, valuesElem); + } + + private static ModbusWriteRequestBlueprint constructBluerint(int subnetId, int unitId, + @Nullable JsonElement functionCodeElem, @Nullable JsonElement addressElem, + @Nullable JsonElement maxTriesElem, @Nullable JsonArray valuesElem) { + int functionCodeNumeric; + if (functionCodeElem == null || functionCodeElem.isJsonNull()) { + throw new IllegalStateException(String.format("Value for '%s' is invalid", JSON_FUNCTION_CODE)); + } + try { + functionCodeNumeric = functionCodeElem.getAsInt(); + } catch (ClassCastException | IllegalStateException e) { + throw new IllegalStateException(String.format("Value for '%s' is invalid", JSON_FUNCTION_CODE), e); + } + ModbusWriteFunctionCode functionCode = ModbusWriteFunctionCode.fromFunctionCode(functionCodeNumeric); + int address; + if (addressElem == null || addressElem.isJsonNull()) { + throw new IllegalStateException(String.format("Value for '%s' is invalid", JSON_ADDRESS)); + } + try { + address = addressElem.getAsInt(); + } catch (ClassCastException | IllegalStateException e) { + throw new IllegalStateException(String.format("Value for '%s' is invalid", JSON_ADDRESS), e); + } + int maxTries; + if (maxTriesElem == null || maxTriesElem.isJsonNull()) { + // Go with default + maxTries = DEFAULT_MAX_TRIES; + } else { + try { + maxTries = maxTriesElem.getAsInt(); + } catch (ClassCastException | IllegalStateException e) { + throw new IllegalStateException(String.format("Value for '%s' is invalid", JSON_MAX_TRIES), e); + } + } + + if (valuesElem == null || valuesElem.isJsonNull()) { + throw new IllegalArgumentException(String.format("Expecting non-null value, got: %s", valuesElem)); + } + + AtomicBoolean writeSingle = new AtomicBoolean(false); + switch (functionCode) { + case WRITE_COIL: + writeSingle.set(true); + if (valuesElem.size() != 1) { + throw new IllegalArgumentException(String + .format("Expecting single value with functionCode=%s, got: %d", functionCode, valuesElem)); + } + // fall-through to WRITE_MULTIPLE_COILS + case WRITE_MULTIPLE_COILS: + if (valuesElem.size() == 0) { + throw new IllegalArgumentException("Must provide at least one coil"); + } else if (valuesElem.size() > ModbusConstants.MAX_BITS_WRITE_COUNT) { + throw new IllegalArgumentException( + String.format("Trying to write too many coils (%d). Maximum is %s", valuesElem.size(), + ModbusConstants.MAX_BITS_WRITE_COUNT)); + } + BitArray bits = new BitArray(valuesElem.size()); + for (int i = 0; i < valuesElem.size(); i++) { + bits.setBit(i, valuesElem.get(i).getAsInt() != 0); + } + return new ModbusWriteCoilRequestBlueprint(subnetId, unitId, address, bits, !writeSingle.get(), + maxTries); + case WRITE_SINGLE_REGISTER: + writeSingle.set(true); + if (valuesElem.size() != 1) { + throw new IllegalArgumentException(String + .format("Expecting single value with functionCode=%s, got: %d", functionCode, valuesElem)); + } + // fall-through to WRITE_MULTIPLE_REGISTERS + case WRITE_MULTIPLE_REGISTERS: { + int[] registers = new int[valuesElem.size()]; + if (registers.length == 0) { + throw new IllegalArgumentException("Must provide at least one register"); + } else if (valuesElem.size() > ModbusConstants.MAX_REGISTERS_WRITE_COUNT) { + throw new IllegalArgumentException( + String.format("Trying to write too many registers (%d). Maximum is %s", valuesElem.size(), + ModbusConstants.MAX_REGISTERS_WRITE_COUNT)); + } + for (int i = 0; i < valuesElem.size(); i++) { + registers[i] = valuesElem.get(i).getAsInt(); + } + return new ModbusWriteRegisterRequestBlueprint(subnetId, unitId, address, + new ModbusRegisterArray(registers), !writeSingle.get(), maxTries); + } + default: + throw new IllegalArgumentException("Unknown function code"); + } + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/AbstractRequestComparer.java b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/AbstractRequestComparer.java new file mode 100644 index 00000000000..32c21c68089 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/AbstractRequestComparer.java @@ -0,0 +1,72 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.test; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.hamcrest.Description; +import org.hamcrest.TypeSafeMatcher; +import org.openhab.core.io.transport.sbus.ModbusWriteFunctionCode; +import org.openhab.core.io.transport.sbus.ModbusWriteRequestBlueprint; + +/** + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +abstract class AbstractRequestComparer extends TypeSafeMatcher { + + private int expectedUnitId; + private int expectedAddress; + private ModbusWriteFunctionCode expectedFunctionCode; + private int expectedMaxTries; + + public AbstractRequestComparer(int expectedUnitId, int expectedAddress, + ModbusWriteFunctionCode expectedFunctionCode, int expectedMaxTries) { + this.expectedUnitId = expectedUnitId; + this.expectedAddress = expectedAddress; + this.expectedFunctionCode = expectedFunctionCode; + this.expectedMaxTries = expectedMaxTries; + } + + @Override + public void describeTo(@NonNullByDefault({}) Description description) { + description.appendText("should return request with"); + description.appendText(" unitID="); + description.appendValue(expectedUnitId); + description.appendText(" address="); + description.appendValue(expectedAddress); + description.appendText(" functionCode="); + description.appendValue(expectedFunctionCode); + description.appendText(" maxTries="); + description.appendValue(expectedMaxTries); + } + + @SuppressWarnings("null") + @Override + protected boolean matchesSafely(T item) { + if (item.getUnitID() != expectedUnitId) { + return false; + } + if (item.getReference() != expectedAddress) { + return false; + } + if (item.getFunctionCode() != expectedFunctionCode) { + return false; + } + if (item.getMaxTries() != expectedMaxTries) { + return false; + } + return doMatchData(item); + } + + protected abstract boolean doMatchData(T item); +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BasicBitArrayTest.java b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BasicBitArrayTest.java new file mode 100644 index 00000000000..f16c145cdd8 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BasicBitArrayTest.java @@ -0,0 +1,89 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.test; + +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.core.io.transport.sbus.BitArray; + +/** + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public class BasicBitArrayTest { + + @Test + public void testGetBitAndSetBit() { + BitArray data1 = new BitArray(true, false, true); + assertThat(data1.size(), is(equalTo(3))); + assertThat(data1.getBit(0), is(equalTo(true))); + assertThat(data1.getBit(1), is(equalTo(false))); + assertThat(data1.getBit(2), is(equalTo(true))); + + data1.setBit(1, true); + data1.setBit(2, false); + assertThat(data1.size(), is(equalTo(3))); + assertThat(data1.getBit(0), is(equalTo(true))); + assertThat(data1.getBit(1), is(equalTo(true))); + assertThat(data1.getBit(2), is(equalTo(false))); + } + + @Test + public void testGetBitAndSetBit2() { + BitArray data1 = new BitArray(3); + assertThat(data1.size(), is(equalTo(3))); + assertThat(data1.getBit(0), is(equalTo(false))); + assertThat(data1.getBit(1), is(equalTo(false))); + assertThat(data1.getBit(2), is(equalTo(false))); + + data1.setBit(1, true); + assertThat(data1.size(), is(equalTo(3))); + assertThat(data1.getBit(0), is(equalTo(false))); + assertThat(data1.getBit(1), is(equalTo(true))); + assertThat(data1.getBit(2), is(equalTo(false))); + + data1.setBit(1, false); + assertThat(data1.size(), is(equalTo(3))); + assertThat(data1.getBit(0), is(equalTo(false))); + assertThat(data1.getBit(1), is(equalTo(false))); + assertThat(data1.getBit(2), is(equalTo(false))); + } + + @Test + public void testOutOfBounds() { + BitArray data1 = new BitArray(true, false, true); + assertThrows(IndexOutOfBoundsException.class, () -> data1.getBit(3)); + } + + @Test + public void testOutOfBounds2() { + BitArray data1 = new BitArray(true, false, true); + assertThrows(IndexOutOfBoundsException.class, () -> data1.getBit(-1)); + } + + @Test + public void testOutOfBounds3() { + BitArray data1 = new BitArray(3); + assertThrows(IndexOutOfBoundsException.class, () -> data1.getBit(3)); + } + + @Test + public void testOutOfBounds4() { + BitArray data1 = new BitArray(3); + assertThrows(IndexOutOfBoundsException.class, () -> data1.getBit(-1)); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesCommandToRegistersTest.java b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesCommandToRegistersTest.java new file mode 100644 index 00000000000..91a79efbe50 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesCommandToRegistersTest.java @@ -0,0 +1,347 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.test; + +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.openhab.core.io.transport.sbus.ModbusConstants.ValueType.*; + +import java.math.BigDecimal; +import java.util.stream.Stream; +import java.util.stream.Stream.Builder; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.openhab.core.io.transport.sbus.ModbusBitUtilities; +import org.openhab.core.io.transport.sbus.ModbusConstants.ValueType; +import org.openhab.core.io.transport.sbus.ModbusRegisterArray; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.IncreaseDecreaseType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.OpenClosedType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.unit.Units; +import org.openhab.core.types.Command; + +/** + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public class BitUtilitiesCommandToRegistersTest { + + private static final int UINT16_MAX = 0xFFFF; + + /** + * Convert given registers as shorts + * + * @param ints registers given as signed value representing the 16bit register values + * @return + */ + private static short[] shorts(int... ints) { + short[] shorts = new short[ints.length]; + for (int i = 0; i < ints.length; i++) { + int possiblyUnsigned = ints[i]; + if (possiblyUnsigned > UINT16_MAX) { + throw new IllegalArgumentException( + "One of the register values, " + possiblyUnsigned + ", is too large (max " + UINT16_MAX + ")"); + } else if (possiblyUnsigned < Short.MIN_VALUE) { + throw new IllegalArgumentException("One of the register values, " + possiblyUnsigned + + ", is too small (min " + Short.MIN_VALUE + "), would truncate to \" + low16Signed"); + } + short low16Signed = (short) possiblyUnsigned; + shorts[i] = low16Signed; + + } + return shorts; + } + + private static class Args implements Arguments { + + private Command command; + private ValueType valueType; + private Object expected; + + public Args(Object command, ValueType valueType, Object expected) { + this.command = command instanceof String s ? new DecimalType(s) : (Command) command; + this.valueType = valueType; + // validating type with cast + if (expected instanceof Integer integer) { + this.expected = shorts(integer); + } else if (expected instanceof Class) { + this.expected = expected; + } else { + this.expected = shorts((int[]) expected); + } + } + + @Override + public Object[] get() { + return new Object[] { command, valueType, expected }; + } + } + + private static class ArgsTemplate { + public Object command; + public Object expected; + + public ArgsTemplate(Object command, Object expected) { + this.command = command; + this.expected = expected; + } + } + + private static Stream parameterize(ValueType[] valueTypes, ArgsTemplate... templates) { + Builder builder = Stream.builder(); + for (ValueType valueType : valueTypes) { + for (ArgsTemplate template : templates) { + builder.add(new Args(template.command, valueType, template.expected)); + } + } + return builder.build(); + } + + private static Stream concatTestArgs(Object... objects) { + Builder builder = Stream.builder(); + for (Object obj : objects) { + if (obj instanceof Args) { + builder.add(obj); + } else if (obj instanceof Stream stream) { + stream.forEach(builder::add); + } else { + throw new IllegalArgumentException("Illegal parameter " + obj.toString()); + } + } + return builder.build(); + } + + private static ArgsTemplate a(Object command, Object expected) { + return new ArgsTemplate(command, expected); + } + + private static ArgsTemplate a(Object command, int... expected) { + return new ArgsTemplate(command, expected); + } + + private static Args a(Object command, ValueType valueType, Object expected) { + return new Args(command, valueType, expected); + } + + private static Args a(Object command, ValueType valueType, int... expected) { + return new Args(command, valueType, expected); + } + + /** + * 2^power + increment + */ + private static String powerOfTwo(int power, int increment) { + return powerOfTwo(1, power, increment); + } + + /** + * (sign)*2^power + increment + */ + private static String powerOfTwo(int sign, int power, int increment) { + BigDecimal dec = new BigDecimal(2).pow(power); + if (sign < 0) { + dec = dec.negate(); + } + dec = dec.add(new BigDecimal(increment)); + return new DecimalType(dec).toString(); + } + + /** + * https://www.rapidtables.com/convert/number/decimal-to-hex.html + * + * @return + */ + public static Stream data() { + return concatTestArgs(// + + a("1.0", BIT, IllegalArgumentException.class), a("1.0", INT8, IllegalArgumentException.class), + a("1.0", UINT8, IllegalArgumentException.class), + // + // INT16 + // + parameterize(new ValueType[] { INT16, UINT16 }, a("1.0", 1), a("1.6", 1), a("2.6", 2), + a("-1004.4", 0xFC14), + // corner cases around signed max value + a(powerOfTwo(15, -2), 0x7ffe), // =sint_max-1. + a(powerOfTwo(15, -1), 0x7fff), // =sint_max. Only high bit set + a(powerOfTwo(15, 0), 0x8000), // = sint_max +1. Wraps to negative when truncated + a(powerOfTwo(15, 1), 0x8001), // = sint_max +2. Wraps to negative when truncated + // corner cases around signed min value + a(powerOfTwo(-1, 15, 1), 0x8001), // =sint_min+1 + a(powerOfTwo(-1, 15, 0), 0x8000), // =sint_min. Only high bit set + a(powerOfTwo(-1, 15, -1), 0x7fff), // = sint_min -1. Wraps to positive when truncated + a(powerOfTwo(-1, 15, -2), 0x7ffe), // = sint_min -2. Wraps to positive when truncated + // corner cases around unsigned max value + a(powerOfTwo(16, -2), 0xfffe), // =uint_max-1. + a(powerOfTwo(16, -1), 0xffff), // =uint_max. + a(powerOfTwo(16, 0), 0x0000), // = uint_max+1. + a(powerOfTwo(16, 1), 0x0001) // = uint_max+2. + ), + + // + // INT32 and UINT32 + // + parameterize(new ValueType[] { INT32, UINT32 }, a("1.0", 0, 1), a("1.6", 0, 1), a("2.6", 0, 2), + a("-1004.4", 0xFFFF, 0xFC14), + // corner cases around signed max value + a(powerOfTwo(31, -2), 0x7fff, 0xfffe), // =sint_max-1. + a(powerOfTwo(31, -1), 0x7fff, 0xffff), // =sint_max. Only high bit set + a(powerOfTwo(31, 0), 0x8000, 0x0000), // = sint_max +1. Wraps to negative when truncated + a(powerOfTwo(31, 1), 0x8000, 0x0001), // = sint_max +2. Wraps to negative when truncated + // corner cases around signed min value + a(powerOfTwo(-1, 31, 1), 0x8000, 0x0001), // =sint_min+1 + a(powerOfTwo(-1, 31, 0), 0x8000, 0x0000), // =sint_min. Only high bit set + a(powerOfTwo(-1, 31, -1), 0x7fff, 0xffff), // = sint_min -1. Wraps to positive when truncated + a(powerOfTwo(-1, 31, -2), 0x7fff, 0xfffe), // = sint_min -2. Wraps to positive when truncated + // corner cases around unsigned max value + a(powerOfTwo(32, -2), 0xffff, 0xfffe), // =uint_max-1. + a(powerOfTwo(32, -1), 0xffff, 0xffff), // =uint_max. + a(powerOfTwo(32, 0), 0x0000, 0x0000), // = uint_max+1. + a(powerOfTwo(32, 1), 0x0000, 0x0001) // = uint_max+2. + + ), + // + // INT32_SWAP and UINT32_SWAP + // + parameterize(new ValueType[] { INT32_SWAP, UINT32_SWAP }, a("1.0", 1, 0), a("1.6", 1, 0), + a("2.6", 2, 0), a("-1004.4", 0xFC14, 0xFFFF), + // corner cases around signed max value + a(powerOfTwo(31, -2), 0xfffe, 0x7fff), // =sint_max-1. + a(powerOfTwo(31, -1), 0xffff, 0x7fff), // =sint_max. Only high bit set + a(powerOfTwo(31, 0), 0x0000, 0x8000), // = sint_max +1. Wraps to negative when truncated + a(powerOfTwo(31, 1), 0x0001, 0x8000), // = sint_max +2. Wraps to negative when truncated + // corner cases around signed min value + a(powerOfTwo(-1, 31, 1), 0x0001, 0x8000), // =sint_min+1 + a(powerOfTwo(-1, 31, 0), 0x0000, 0x8000), // =sint_min. Only high bit set + a(powerOfTwo(-1, 31, -1), 0xffff, 0x7fff), // = sint_min -1. Wraps to positive when truncated + a(powerOfTwo(-1, 31, -2), 0xfffe, 0x7fff), // = sint_min -2. Wraps to positive when truncated + // corner cases around unsigned max value + a(powerOfTwo(32, -2), 0xfffe, 0xffff), // =uint_max-1. + a(powerOfTwo(32, -1), 0xffff, 0xffff), // =uint_max. + a(powerOfTwo(32, 0), 0x0000, 0x0000), // = uint_max+1. + a(powerOfTwo(32, 1), 0x0001, 0x0000) // = uint_max+2. + + ), + // + // INT64 and UINT64 + // + parameterize(new ValueType[] { INT64, UINT64 }, a("1.0", 0, 0, 0, 1), a("1.6", 0, 0, 0, 1), + a("2.6", 0, 0, 0, 2), a("-1004.4", 0xFFFF, 0xFFFF, 0xFFFF, 0xFC14), + // corner cases around signed max value + a(powerOfTwo(63, -2), 0x7fff, 0xffff, 0xffff, 0xfffe), // =sint_max-1. + a(powerOfTwo(63, -1), 0x7fff, 0xffff, 0xffff, 0xffff), // =sint_max. Only high bit set + a(powerOfTwo(63, 0), 0x8000, 0x0000, 0x0000, 0x0000), // = sint_max +1. Wraps to negative when + // truncated + a(powerOfTwo(63, 1), 0x8000, 0x0000, 0x0000, 0x0001), // = sint_max +2. Wraps to negative when + // truncated + // corner cases around signed min value + a(powerOfTwo(-1, 63, 1), 0x8000, 0x0000, 0x0000, 0x0001), // =sint_min+1 + a(powerOfTwo(-1, 63, 0), 0x8000, 0x0000, 0x0000, 0x0000), // =sint_min. Only high bit set + a(powerOfTwo(-1, 63, -1), 0x7fff, 0xffff, 0xffff, 0xffff), // = sint_min -1. Wraps to positive + // when truncated + a(powerOfTwo(-1, 63, -2), 0x7fff, 0xffff, 0xffff, 0xfffe), // = sint_min -2. Wraps to positive + // when truncated + // corner cases around unsigned max value + a(powerOfTwo(64, -2), 0xffff, 0xffff, 0xffff, 0xfffe), // =uint_max-1. + a(powerOfTwo(64, -1), 0xffff, 0xffff, 0xffff, 0xffff), // =uint_max. + a(powerOfTwo(64, 0), 0x0000, 0x0000, 0x0000, 0x0000), // = uint_max+1. + a(powerOfTwo(64, 1), 0x0000, 0x0000, 0x0000, 0x0001) // = uint_max+2. + + ), + // + // INT64_SWAP and UINT64_SWAP + // + parameterize(new ValueType[] { INT64_SWAP, UINT64_SWAP }, a("1.0", 1, 0, 0, 0), a("1.6", 1, 0, 0, 0), + a("2.6", 2, 0, 0, 0), a("-1004.4", 0xFC14, 0xFFFF, 0xFFFF, 0xFFFF), + // corner cases around signed max value + a(powerOfTwo(63, -2), 0xfffe, 0xffff, 0xffff, 0x7fff), // =sint_max-1. + a(powerOfTwo(63, -1), 0xffff, 0xffff, 0xffff, 0x7fff), // =sint_max. Only high bit set + a(powerOfTwo(63, 0), 0x0000, 0x0000, 0x0000, 0x8000), // = sint_max +1. Wraps to negative when + // truncated + a(powerOfTwo(63, 1), 0x0001, 0x0000, 0x0000, 0x8000), // = sint_max +2. Wraps to negative when + // truncated + // corner cases around signed min value + a(powerOfTwo(-1, 63, 1), 0x0001, 0x0000, 0x0000, 0x8000), // =sint_min+1 + a(powerOfTwo(-1, 63, 0), 0x0000, 0x0000, 0x0000, 0x8000), // =sint_min. Only high bit set + a(powerOfTwo(-1, 63, -1), 0xffff, 0xffff, 0xffff, 0x7fff), // = sint_min -1. Wraps to positive + // when truncated + a(powerOfTwo(-1, 63, -2), 0xfffe, 0xffff, 0xffff, 0x7fff), // = sint_min -2. Wraps to positive + // when truncated + // corner cases around unsigned max value + a(powerOfTwo(64, -2), 0xfffe, 0xffff, 0xffff, 0xffff), // =uint_max-1. + a(powerOfTwo(64, -1), 0xffff, 0xffff, 0xffff, 0xffff), // =uint_max. + a(powerOfTwo(64, 0), 0x0000, 0x0000, 0x0000, 0x0000), // = uint_max+1. + a(powerOfTwo(64, 1), 0x0001, 0x0000, 0x0000, 0x0000) // = uint_max+2. + + ), + // + // FLOAT32 + // + a("1.0", FLOAT32, 0x3F80, 0x0000), a("1.6", FLOAT32, 0x3FCC, 0xCCCD), a("2.6", FLOAT32, 0x4026, 0x6666), + a("-1004.4", FLOAT32, 0xC47B, 0x199A), a("64000", FLOAT32, 0x477A, 0x0000), + a("70004.4", FLOAT32, 0x4788, 0xBA33), a("5000000000", FLOAT32, 0x4F95, 0x02F9), + // + // FLOAT32_SWAP + // + a("1.0", FLOAT32_SWAP, 0x0000, 0x3F80), a("1.6", FLOAT32_SWAP, 0xCCCD, 0x3FCC), + a("2.6", FLOAT32_SWAP, 0x6666, 0x4026), a("-1004.4", FLOAT32_SWAP, 0x199A, 0xC47B), + a("64000", FLOAT32_SWAP, 0x0000, 0x477A), a("70004.4", FLOAT32_SWAP, 0xBA33, 0x4788), + a("5000000000", FLOAT32_SWAP, 0x02F9, 0x4F95), + // ON/OFF + a(OnOffType.ON, FLOAT32_SWAP, 0x0000, 0x3F80), a(OnOffType.OFF, FLOAT32_SWAP, 0x0000, 0x0000), + // OPEN + a(OpenClosedType.OPEN, FLOAT32_SWAP, 0x0000, 0x3F80), a(OpenClosedType.OPEN, INT16, 1), + // CLOSED + a(OpenClosedType.CLOSED, FLOAT32_SWAP, 0x0000, 0x0000), a(OpenClosedType.CLOSED, INT16, 0x0000), + // QuantityType, dimensionless units are converted to unit of 1. e.g. 500% -> 5 + a(QuantityType.valueOf(500, Units.PERCENT), INT16, 0x0005), + // 50% = 0.5 truncated to zero + a(QuantityType.valueOf(50, Units.PERCENT), INT16, 0x0000), + a(QuantityType.valueOf(6, Units.ONE), INT16, 0x0006), + // QuantityType, non-dimensionless not supported + a(QuantityType.valueOf(5, Units.KELVIN), INT16, IllegalArgumentException.class), + // Unsupported command + a(IncreaseDecreaseType.INCREASE, FLOAT32_SWAP, IllegalArgumentException.class) + + ); + } + + @SuppressWarnings("unchecked") + @ParameterizedTest + @MethodSource("data") + public void testCommandToRegisters(Command command, ValueType type, Object expectedResult) { + if (expectedResult instanceof Class class1 && Exception.class.isAssignableFrom(class1)) { + assertThrows(class1, () -> ModbusBitUtilities.commandToRegisters(command, type)); + return; + } + + ModbusRegisterArray registers = ModbusBitUtilities.commandToRegisters(command, type); + short[] expectedRegisters = (short[]) expectedResult; + + assertThat(String.format("register index command=%s, type=%s", command, type), registers.size(), + is(equalTo(expectedRegisters.length))); + for (int i = 0; i < expectedRegisters.length; i++) { + int expectedRegisterDataUnsigned = expectedRegisters[i] & 0xffff; + int actualUnsigned = registers.getRegister(i); + + assertThat(String.format("register index i=%d, command=%s, type=%s", i, command, type), actualUnsigned, + is(equalTo(expectedRegisterDataUnsigned))); + } + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesExtractBitTest.java b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesExtractBitTest.java new file mode 100644 index 00000000000..13e725e696d --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesExtractBitTest.java @@ -0,0 +1,136 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.test; + +import static org.junit.jupiter.api.Assertions.*; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.core.io.transport.sbus.ModbusBitUtilities; + +/** + * + * Tests for extractBit + * + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public class BitUtilitiesExtractBitTest { + + @Test + public void testExtractBitWithRegisterIndexAndBitIndex() { + byte[] bytes = new byte[] { 0b00100001, // hi byte of 1st register + 0b00100101, // lo byte of 1st register + 0b00110001, // hi byte of 2nd register + 0b00101001 }; // lo byte of 2nd register + + { + int registerIndex = 0; + int[] expectedBitsFromLSBtoMSB = new int[] { // + 1, 0, 1, 0, 0, 1, 0, 0, // lo byte, with increasing significance + 1, 0, 0, 0, 0, 1, 0, 0 // hi byte, with increasing significance + }; + for (int bitIndex = 0; bitIndex < expectedBitsFromLSBtoMSB.length; bitIndex++) { + assertEquals(expectedBitsFromLSBtoMSB[bitIndex], + ModbusBitUtilities.extractBit(bytes, registerIndex, bitIndex), + String.format("bitIndex=%d", bitIndex)); + } + } + { + int registerIndex = 1; + int[] expectedBitsFromLSBtoMSB = new int[] { // + 1, 0, 0, 1, 0, 1, 0, 0, // lo byte, with increasing significance + 1, 0, 0, 0, 1, 1, 0, 0 // hi byte, with increasing significance + }; + for (int bitIndex = 0; bitIndex < expectedBitsFromLSBtoMSB.length; bitIndex++) { + assertEquals(expectedBitsFromLSBtoMSB[bitIndex], + ModbusBitUtilities.extractBit(bytes, registerIndex, bitIndex), + String.format("bitIndex=%d", bitIndex)); + } + } + } + + @Test + public void testExtractBitWithRegisterIndexAndBitIndexOOB() { + byte[] bytes = new byte[] { 0b00100001, // hi byte of 1st register + 0b00100101, // lo byte of 1st register + 0b00110001, // hi byte of 2nd register + 0b00101001 }; // lo byte of 2nd register + assertThrows(IllegalArgumentException.class, () -> ModbusBitUtilities.extractBit(bytes, 3, 0)); + } + + @Test + public void testExtractBitWithRegisterIndexAndBitIndexOOB2() { + byte[] bytes = new byte[] { 0b00100001, // hi byte of 1st register + 0b00100101, // lo byte of 1st register + 0b00110001, // hi byte of 2nd register + 0b00101001 }; // lo byte of 2nd register + assertThrows(IllegalArgumentException.class, () -> ModbusBitUtilities.extractBit(bytes, 0, 17)); + } + + @Test + public void testExtractBitWithRegisterIndexAndBitIndexOOB3() { + byte[] bytes = new byte[] { 0b00100001, // hi byte of 1st register + 0b00100101, // lo byte of 1st register + 0b00110001, // hi byte of 2nd register + 0b00101001 }; // lo byte of 2nd register + assertThrows(IllegalArgumentException.class, () -> ModbusBitUtilities.extractBit(bytes, 0, -1)); + } + + @Test + public void testExtractBitWithRegisterIndexAndBitIndexOOB4() { + byte[] bytes = new byte[] { 0b00100001, // hi byte of 1st register + 0b00100101, // lo byte of 1st register + 0b00110001, // hi byte of 2nd register + 0b00101001 }; // lo byte of 2nd register + assertThrows(IllegalArgumentException.class, () -> ModbusBitUtilities.extractBit(bytes, -1, 0)); + } + + @Test + public void testExtractBitWithSingleIndex() { + byte[] bytes = new byte[] { 0b00100001, // hi byte of 1st register + 0b00100101, // lo byte of 1st register + 0b00110001, // hi byte of 2nd register + 0b00101001 }; // lo byte of 2nd register + int[] expectedBits = new int[] { // + 1, 0, 1, 0, 0, 1, 0, 0, // 1st register: lo byte, with increasing significance + 1, 0, 0, 0, 0, 1, 0, 0, // 1st register: hi byte, with increasing significance + 1, 0, 0, 1, 0, 1, 0, 0, // 2nd register: lo byte, with increasing significance + 1, 0, 0, 0, 1, 1, 0, 0 // 2nd register: hi byte, with increasing significance + }; + for (int bitIndex = 0; bitIndex < expectedBits.length; bitIndex++) { + assertEquals(expectedBits[bitIndex], ModbusBitUtilities.extractBit(bytes, bitIndex), + String.format("bitIndex=%d", bitIndex)); + assertEquals(expectedBits[bitIndex], ModbusBitUtilities.extractBit(bytes, bitIndex), + String.format("bitIndex=%d", bitIndex)); + } + } + + @Test + public void testExtractBitWithSingleIndexOOB() { + byte[] bytes = new byte[] { 0b00100001, // hi byte of 1st register + 0b00100101, // lo byte of 1st register + 0b00110001, // hi byte of 2nd register + 0b00101001 }; // lo byte of 2nd register + assertThrows(IllegalArgumentException.class, () -> ModbusBitUtilities.extractBit(bytes, 32)); + } + + @Test + public void testExtractBitWithSingleIndexOOB2() { + byte[] bytes = new byte[] { 0b00100001, // hi byte of 1st register + 0b00100101, // lo byte of 1st register + 0b00110001, // hi byte of 2nd register + 0b00101001 }; // lo byte of 2nd register + assertThrows(IllegalArgumentException.class, () -> ModbusBitUtilities.extractBit(bytes, -1)); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesExtractFloat32Test.java b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesExtractFloat32Test.java new file mode 100644 index 00000000000..7d1345a5592 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesExtractFloat32Test.java @@ -0,0 +1,82 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.ByteBuffer; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.core.io.transport.sbus.ModbusBitUtilities; + +/** + * + * Tests for 'special' float values such as infinity and NaN. These are not covered in detail in + * {@link BitUtilitiesExtractIndividualMethodsTest} and + * {@link BitUtilitiesExtractStateFromRegistersTest} + * + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public class BitUtilitiesExtractFloat32Test { + + /** + * Creates a byte array with byteOffset number of zeroes, followed by 32bit of data represented by data + * + * @param data actual data payload + * @param byteOffset number of zeros padded + * @return byte array of size 4 + byteOffset + */ + private static byte[] bytes(int data, int byteOffset) { + ByteBuffer buffer = ByteBuffer.allocate(4 + byteOffset); + for (int i = 0; i < byteOffset; i++) { + buffer.put((byte) 0); + } + buffer.putInt(data); + return buffer.array(); + } + + private static void testFloat(float number) { + int data = Float.floatToIntBits(number); + for (int byteOffset = 0; byteOffset < 5; byteOffset++) { + byte[] bytes = bytes(data, byteOffset); + float actual = ModbusBitUtilities.extractFloat32(bytes, byteOffset); + float expected = Float.intBitsToFloat(data); + // Strict comparison of the float values with the exception of NaN + assertTrue(Float.isNaN(expected) ? Float.isNaN(actual) : expected == actual, + String.format("Testing %f (%s) with offset %d, got %f (%s)", expected, Integer.toBinaryString(data), + byteOffset, actual, Integer.toBinaryString(Float.floatToRawIntBits(actual)))); + } + } + + @Test + public void testExtractFloat32Inf() { + testFloat(Float.POSITIVE_INFINITY); + } + + @Test + public void testExtractFloat32NegInf() { + testFloat(Float.NEGATIVE_INFINITY); + } + + @Test + public void testExtractFloat32NaN() { + testFloat(Float.NaN); + } + + @Test + public void testExtractFloat32Regular() { + testFloat(1.3f); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesExtractIndividualMethodsTest.java b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesExtractIndividualMethodsTest.java new file mode 100644 index 00000000000..79c6293f5c1 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesExtractIndividualMethodsTest.java @@ -0,0 +1,266 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.test; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Stream; +import java.util.stream.Stream.Builder; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.openhab.core.io.transport.sbus.ModbusBitUtilities; +import org.openhab.core.io.transport.sbus.ModbusConstants.ValueType; +import org.openhab.core.io.transport.sbus.ModbusRegisterArray; +import org.openhab.core.library.types.DecimalType; + +/** + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public class BitUtilitiesExtractIndividualMethodsTest { + + public static Collection data() { + // We use test data from BitUtilitiesExtractStateFromRegistersTest + // In BitUtilitiesExtractStateFromRegistersTest the data is aligned to registers + // + // Here (in registerVariations) we generate offsetted variations of the byte data + // to test extractXX which can operate on data aligned on byte-level, not just data aligned on-register level + Collection data = BitUtilitiesExtractStateFromRegistersTest.data(); + return data.stream().flatMap(values -> { + Object expectedResult = values[0]; + ValueType type = (ValueType) values[1]; + ModbusRegisterArray registers = (ModbusRegisterArray) values[2]; + int index = (int) values[3]; + return registerVariations(expectedResult, type, registers, index); + }).toList(); + } + + public static Stream filteredTestData(ValueType type) { + return data().stream().filter(values -> (ValueType) values[1] == type); + } + + /** + * Generate register variations for extractXX functions + * + * + * @return entries of (byte[], byteIndex) + */ + private static Stream registerVariations(Object expectedResult, ValueType type, + ModbusRegisterArray registers, int index) { + byte[] origBytes = registers.getBytes(); + int origRegisterIndex = index; + int origByteIndex = origRegisterIndex * 2; + + Builder streamBuilder = Stream.builder(); + for (int offset = 0; offset < 5; offset++) { + int byteIndex = origByteIndex + offset; + byte[] bytesOffsetted = new byte[origBytes.length + offset]; + for (int i = 0; i < bytesOffsetted.length; i++) { + bytesOffsetted[i] = 99; + } + System.arraycopy(origBytes, 0, bytesOffsetted, offset, origBytes.length); + // offsetted: + streamBuilder.add(new Object[] { expectedResult, type, bytesOffsetted, byteIndex }); + + // offsetted, with no extra bytes following + // (this is only done for successful cases to avoid copyOfRange padding with zeros + if (!(expectedResult instanceof Class)) { + byte[] bytesOffsettedCutExtra = Arrays.copyOfRange(bytesOffsetted, 0, byteIndex + type.getBits() / 8); + if (bytesOffsettedCutExtra.length != bytesOffsetted.length) { + streamBuilder.add(new Object[] { expectedResult, type, bytesOffsettedCutExtra, byteIndex }); + } + } + } + return streamBuilder.build(); + } + + private void testIndividual(Object expectedResult, ValueType type, byte[] bytes, int byteIndex, + Supplier methodUnderTest, Function expectedPrimitive) { + testIndividual(expectedResult, type, bytes, byteIndex, methodUnderTest, expectedPrimitive, null); + } + + @SuppressWarnings("unchecked") + private void testIndividual(Object expectedResult, ValueType type, byte[] bytes, int byteIndex, + Supplier methodUnderTest, Function expectedPrimitive, + @Nullable Number defaultWhenEmptyOptional) { + String testExplanation = String.format("bytes=%s, byteIndex=%d, type=%s", Arrays.toString(bytes), byteIndex, + type); + final Object expectedNumber; + if (expectedResult instanceof Class class1 && Exception.class.isAssignableFrom(class1)) { + assertThrows((Class) expectedResult, methodUnderTest::get); + } else if (expectedResult instanceof Optional optional) { + assertTrue(optional.isEmpty()); + if (defaultWhenEmptyOptional == null) { + fail("Should provide defaultWhenEmptyOptional"); + } + return; + } else { + DecimalType expectedDecimal = (DecimalType) expectedResult; + expectedNumber = expectedPrimitive.apply(expectedDecimal); + assertEquals(expectedNumber, methodUnderTest.get(), testExplanation); + } + } + + public static Stream filteredTestDataSInt16() { + return filteredTestData(ValueType.INT16); + } + + @ParameterizedTest + @MethodSource("filteredTestDataSInt16") + public void testExtractIndividualSInt16(Object expectedResult, ValueType type, byte[] bytes, int byteIndex) + throws InstantiationException, IllegalAccessException { + testIndividual(expectedResult, type, bytes, byteIndex, () -> ModbusBitUtilities.extractSInt16(bytes, byteIndex), + Number::shortValue); + } + + public static Stream filteredTestDataUInt16() { + return filteredTestData(ValueType.UINT16); + } + + @ParameterizedTest + @MethodSource("filteredTestDataUInt16") + public void testExtractIndividualUInt16(Object expectedResult, ValueType type, byte[] bytes, int byteIndex) + throws InstantiationException, IllegalAccessException { + testIndividual(expectedResult, type, bytes, byteIndex, () -> ModbusBitUtilities.extractUInt16(bytes, byteIndex), + DecimalType::intValue); + } + + public static Stream filteredTestDataSInt32() { + return filteredTestData(ValueType.INT32); + } + + @ParameterizedTest + @MethodSource("filteredTestDataSInt32") + public void testExtractIndividualSInt32(Object expectedResult, ValueType type, byte[] bytes, int byteIndex) + throws InstantiationException, IllegalAccessException { + testIndividual(expectedResult, type, bytes, byteIndex, () -> ModbusBitUtilities.extractSInt32(bytes, byteIndex), + DecimalType::intValue); + } + + public static Stream filteredTestDataUInt32() { + return filteredTestData(ValueType.UINT32); + } + + @ParameterizedTest + @MethodSource("filteredTestDataUInt32") + public void testExtractIndividualUInt32(Object expectedResult, ValueType type, byte[] bytes, int byteIndex) + throws InstantiationException, IllegalAccessException { + testIndividual(expectedResult, type, bytes, byteIndex, () -> ModbusBitUtilities.extractUInt32(bytes, byteIndex), + DecimalType::longValue); + } + + public static Stream filteredTestDataSInt32Swap() { + return filteredTestData(ValueType.INT32_SWAP); + } + + @ParameterizedTest + @MethodSource("filteredTestDataSInt32Swap") + public void testExtractIndividualSInt32Swap(Object expectedResult, ValueType type, byte[] bytes, int byteIndex) + throws InstantiationException, IllegalAccessException { + testIndividual(expectedResult, type, bytes, byteIndex, + () -> ModbusBitUtilities.extractSInt32Swap(bytes, byteIndex), DecimalType::intValue); + } + + public static Stream filteredTestDataUInt32Swap() { + return filteredTestData(ValueType.UINT32_SWAP); + } + + @ParameterizedTest + @MethodSource("filteredTestDataUInt32Swap") + public void testExtractIndividualUInt32Swap(Object expectedResult, ValueType type, byte[] bytes, int byteIndex) + throws InstantiationException, IllegalAccessException { + testIndividual(expectedResult, type, bytes, byteIndex, + () -> ModbusBitUtilities.extractUInt32Swap(bytes, byteIndex), DecimalType::longValue); + } + + public static Stream filteredTestDataSInt64() { + return filteredTestData(ValueType.INT64); + } + + @ParameterizedTest + @MethodSource("filteredTestDataSInt64") + public void testExtractIndividualSInt64(Object expectedResult, ValueType type, byte[] bytes, int byteIndex) + throws InstantiationException, IllegalAccessException { + testIndividual(expectedResult, type, bytes, byteIndex, () -> ModbusBitUtilities.extractSInt64(bytes, byteIndex), + DecimalType::longValue); + } + + public static Stream filteredTestDataUInt64() { + return filteredTestData(ValueType.UINT64); + } + + @ParameterizedTest + @MethodSource("filteredTestDataUInt64") + public void testExtractIndividualUInt64(Object expectedResult, ValueType type, byte[] bytes, int byteIndex) + throws InstantiationException, IllegalAccessException { + testIndividual(expectedResult, type, bytes, byteIndex, () -> ModbusBitUtilities.extractUInt64(bytes, byteIndex), + decimal -> decimal.toBigDecimal().toBigIntegerExact()); + } + + public static Stream filteredTestDataSInt64Swap() { + return filteredTestData(ValueType.INT64_SWAP); + } + + @ParameterizedTest + @MethodSource("filteredTestDataSInt64Swap") + public void testExtractIndividualSInt64Swap(Object expectedResult, ValueType type, byte[] bytes, int byteIndex) + throws InstantiationException, IllegalAccessException { + testIndividual(expectedResult, type, bytes, byteIndex, + () -> ModbusBitUtilities.extractSInt64Swap(bytes, byteIndex), DecimalType::longValue); + } + + public static Stream filteredTestDataUInt64Swap() { + return filteredTestData(ValueType.UINT64_SWAP); + } + + @ParameterizedTest + @MethodSource("filteredTestDataUInt64Swap") + public void testExtractIndividualUInt64Swap(Object expectedResult, ValueType type, byte[] bytes, int byteIndex) + throws InstantiationException, IllegalAccessException { + testIndividual(expectedResult, type, bytes, byteIndex, + () -> ModbusBitUtilities.extractUInt64Swap(bytes, byteIndex), + decimal -> decimal.toBigDecimal().toBigIntegerExact()); + } + + public static Stream filteredTestDataFloat32() { + return filteredTestData(ValueType.FLOAT32); + } + + @ParameterizedTest + @MethodSource("filteredTestDataFloat32") + public void testExtractIndividualFloat32(Object expectedResult, ValueType type, byte[] bytes, int byteIndex) + throws InstantiationException, IllegalAccessException { + testIndividual(expectedResult, type, bytes, byteIndex, + () -> ModbusBitUtilities.extractFloat32(bytes, byteIndex), DecimalType::floatValue, Float.NaN); + } + + public static Stream filteredTestDataFloat32Swap() { + return filteredTestData(ValueType.FLOAT32_SWAP); + } + + @ParameterizedTest + @MethodSource("filteredTestDataFloat32Swap") + public void testExtractIndividualFloat32Swap(Object expectedResult, ValueType type, byte[] bytes, int byteIndex) + throws InstantiationException, IllegalAccessException { + testIndividual(expectedResult, type, bytes, byteIndex, + () -> ModbusBitUtilities.extractFloat32Swap(bytes, byteIndex), DecimalType::floatValue, Float.NaN); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesExtractInt8Test.java b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesExtractInt8Test.java new file mode 100644 index 00000000000..8fbe72cce44 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesExtractInt8Test.java @@ -0,0 +1,115 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.test; + +import static org.junit.jupiter.api.Assertions.*; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.core.io.transport.sbus.ModbusBitUtilities; + +/** + * + * Tests for extractSInt8 and extractUInt8 + * + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public class BitUtilitiesExtractInt8Test { + + @Test + public void extractSInt8WithSingleIndex() { + byte[] bytes = new byte[] { -1, 2, 3 }; + assertEquals(-1, ModbusBitUtilities.extractSInt8(bytes, 0)); + assertEquals(2, ModbusBitUtilities.extractSInt8(bytes, 1)); + assertEquals(3, ModbusBitUtilities.extractSInt8(bytes, 2)); + } + + @Test + public void extractSInt8WithSingleIndexOOB() { + byte[] bytes = new byte[] { -1, 2, 3 }; + assertThrows(IllegalArgumentException.class, () -> ModbusBitUtilities.extractSInt8(bytes, 3)); + } + + @Test + public void extractSInt8WithSingleIndexOOB2() { + byte[] bytes = new byte[] { -1, 2, 3 }; + assertThrows(IllegalArgumentException.class, () -> ModbusBitUtilities.extractSInt8(bytes, -1)); + } + + @Test + public void extractSInt8WithRegisterIndexAndHiByte() { + byte[] bytes = new byte[] { -1, 2, 3, 4 }; + assertEquals(-1, ModbusBitUtilities.extractSInt8(bytes, 0, true)); + assertEquals(2, ModbusBitUtilities.extractSInt8(bytes, 0, false)); + assertEquals(3, ModbusBitUtilities.extractSInt8(bytes, 1, true)); + assertEquals(4, ModbusBitUtilities.extractSInt8(bytes, 1, false)); + } + + @Test + public void extractSInt8WithRegisterIndexAndHiByteOOB() { + byte[] bytes = new byte[] { -1, 2, 3, 4 }; + assertThrows(IllegalArgumentException.class, () -> ModbusBitUtilities.extractSInt8(bytes, 2, true)); + } + + @Test + public void extractSInt8WithRegisterIndexAndHiByteOOB2() { + byte[] bytes = new byte[] { -1, 2, 3, 4 }; + assertThrows(IllegalArgumentException.class, () -> ModbusBitUtilities.extractSInt8(bytes, -1, true)); + } + + // + // unsigned int8 follows + // + + @Test + public void extractUInt8WithSingleIndex() { + byte[] bytes = new byte[] { -1, 2, 3 }; + assertEquals(255, ModbusBitUtilities.extractUInt8(bytes, 0)); + assertEquals(2, ModbusBitUtilities.extractUInt8(bytes, 1)); + assertEquals(3, ModbusBitUtilities.extractUInt8(bytes, 2)); + } + + @Test + public void extractUInt8WithSingleIndexOOB() { + byte[] bytes = new byte[] { -1, 2, 3 }; + assertThrows(IllegalArgumentException.class, () -> ModbusBitUtilities.extractUInt8(bytes, 3)); + } + + @Test + public void extractUInt8WithSingleIndexOOB2() { + byte[] bytes = new byte[] { -1, 2, 3 }; + assertThrows(IllegalArgumentException.class, () -> ModbusBitUtilities.extractUInt8(bytes, -1)); + } + + @Test + public void extractUInt8WithRegisterIndexAndHiByte() { + byte[] bytes = new byte[] { -1, 2, 3, 4 }; + assertEquals(255, ModbusBitUtilities.extractUInt8(bytes, 0, true)); + assertEquals(2, ModbusBitUtilities.extractUInt8(bytes, 0, false)); + assertEquals(3, ModbusBitUtilities.extractUInt8(bytes, 1, true)); + assertEquals(4, ModbusBitUtilities.extractUInt8(bytes, 1, false)); + } + + @Test + public void extractUInt8WithRegisterIndexAndHiByteOOB() { + byte[] bytes = new byte[] { -1, 2, 3, 4 }; + assertThrows(IllegalArgumentException.class, () -> ModbusBitUtilities.extractUInt8(bytes, 2, true)); + } + + @Test + public void extractUInt8WithRegisterIndexAndHiByteOOB2() { + byte[] bytes = new byte[] { -1, 2, 3, 4 }; + assertThrows(IllegalArgumentException.class, () -> ModbusBitUtilities.extractUInt8(bytes, 255, true)); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesExtractStateFromRegistersTest.java b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesExtractStateFromRegistersTest.java new file mode 100644 index 00000000000..0241512b5b2 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesExtractStateFromRegistersTest.java @@ -0,0 +1,371 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.test; + +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.openhab.core.io.transport.sbus.ModbusBitUtilities; +import org.openhab.core.io.transport.sbus.ModbusConstants.ValueType; +import org.openhab.core.io.transport.sbus.ModbusRegisterArray; +import org.openhab.core.library.types.DecimalType; + +/** + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public class BitUtilitiesExtractStateFromRegistersTest { + + private static ModbusRegisterArray shortArrayToRegisterArray(int... arr) { + return new ModbusRegisterArray(arr); + } + + public static Collection data() { + return List.of( + // + // BIT + // + new Object[] { new DecimalType("1.0"), ValueType.BIT, + shortArrayToRegisterArray(1 << 5 | 1 << 4 | 1 << 15), 4 }, + new Object[] { new DecimalType("1.0"), ValueType.BIT, + shortArrayToRegisterArray(1 << 5 | 1 << 4 | 1 << 15), 15 }, + new Object[] { new DecimalType("0.0"), ValueType.BIT, shortArrayToRegisterArray(1 << 5), 7 }, + new Object[] { new DecimalType("1.0"), ValueType.BIT, shortArrayToRegisterArray(1 << 5), 5 }, + new Object[] { new DecimalType("0.0"), ValueType.BIT, shortArrayToRegisterArray(1 << 5), 4 }, + new Object[] { new DecimalType("0.0"), ValueType.BIT, shortArrayToRegisterArray(1 << 5), 0 }, + new Object[] { new DecimalType("0.0"), ValueType.BIT, shortArrayToRegisterArray(0, 0), 15 }, + new Object[] { new DecimalType("1.0"), ValueType.BIT, shortArrayToRegisterArray(1 << 5, 1 << 4), 5 }, + new Object[] { new DecimalType("1.0"), ValueType.BIT, shortArrayToRegisterArray(1 << 5, 1 << 4), 20 }, + new Object[] { IllegalArgumentException.class, ValueType.BIT, shortArrayToRegisterArray(1 << 5), 16 }, + new Object[] { IllegalArgumentException.class, ValueType.BIT, shortArrayToRegisterArray(1 << 5), 200 }, + new Object[] { IllegalArgumentException.class, ValueType.BIT, shortArrayToRegisterArray(), 0 }, + new Object[] { IllegalArgumentException.class, ValueType.BIT, shortArrayToRegisterArray(0, 0), 32 }, + // + // INT8 + // + new Object[] { new DecimalType("5.0"), ValueType.INT8, shortArrayToRegisterArray(5), 0 }, + new Object[] { new DecimalType("-5.0"), ValueType.INT8, shortArrayToRegisterArray(-5), 0 }, + new Object[] { new DecimalType("3.0"), ValueType.INT8, + shortArrayToRegisterArray(((byte) 6 << 8) | (byte) 3), 0 }, + new Object[] { new DecimalType("6.0"), ValueType.INT8, + shortArrayToRegisterArray(((byte) 6 << 8) | (byte) 3), 1 }, + new Object[] { new DecimalType("4.0"), ValueType.INT8, + shortArrayToRegisterArray(((byte) 6 << 8) | (byte) 3, 4), 2 }, + new Object[] { new DecimalType("6.0"), ValueType.INT8, + shortArrayToRegisterArray(55, ((byte) 6 << 8) | (byte) 3), 3 }, + new Object[] { IllegalArgumentException.class, ValueType.INT8, shortArrayToRegisterArray(1), 2 }, + new Object[] { IllegalArgumentException.class, ValueType.INT8, shortArrayToRegisterArray(1, 2), 4 }, + // + // UINT8 + // + new Object[] { new DecimalType("5.0"), ValueType.UINT8, shortArrayToRegisterArray(5), 0 }, + new Object[] { new DecimalType("251.0"), ValueType.UINT8, shortArrayToRegisterArray(-5), 0 }, + new Object[] { new DecimalType("3.0"), ValueType.UINT8, + shortArrayToRegisterArray(((byte) 6 << 8) | (byte) 3), 0 }, + new Object[] { new DecimalType("6.0"), ValueType.UINT8, + shortArrayToRegisterArray(((byte) 6 << 8) | (byte) 3), 1 }, + new Object[] { new DecimalType("4.0"), ValueType.UINT8, + shortArrayToRegisterArray(((byte) 6 << 8) | (byte) 3, 4), 2 }, + new Object[] { new DecimalType("6.0"), ValueType.UINT8, + shortArrayToRegisterArray(55, ((byte) 6 << 8) | (byte) 3), 3 }, + new Object[] { IllegalArgumentException.class, ValueType.UINT8, shortArrayToRegisterArray(1), 2 }, + new Object[] { IllegalArgumentException.class, ValueType.UINT8, shortArrayToRegisterArray(1, 2), 4 }, + + // + // INT16 + // + new Object[] { new DecimalType("1.0"), ValueType.INT16, shortArrayToRegisterArray(1), 0 }, + new Object[] { new DecimalType("2.0"), ValueType.INT16, shortArrayToRegisterArray(2), 0 }, + new Object[] { new DecimalType("-1004"), ValueType.INT16, shortArrayToRegisterArray(-1004), 0 }, + new Object[] { new DecimalType("-1536"), ValueType.INT16, shortArrayToRegisterArray(64000), 0 }, + new Object[] { new DecimalType("-1004"), ValueType.INT16, shortArrayToRegisterArray(4, -1004), 1 }, + new Object[] { new DecimalType("-1004"), ValueType.INT16, shortArrayToRegisterArray(-1004, 4), 0 }, + new Object[] { IllegalArgumentException.class, ValueType.INT16, shortArrayToRegisterArray(4, -1004), + 2 }, + // + // UINT16 + // + new Object[] { new DecimalType("1.0"), ValueType.UINT16, shortArrayToRegisterArray(1), 0 }, + new Object[] { new DecimalType("2.0"), ValueType.UINT16, shortArrayToRegisterArray(2), 0 }, + new Object[] { new DecimalType("64532"), ValueType.UINT16, shortArrayToRegisterArray(-1004), 0 }, + new Object[] { new DecimalType("64000"), ValueType.UINT16, shortArrayToRegisterArray(64000), 0 }, + new Object[] { new DecimalType("64532"), ValueType.UINT16, shortArrayToRegisterArray(4, -1004), 1 }, + new Object[] { new DecimalType("64532"), ValueType.UINT16, shortArrayToRegisterArray(-1004, 4), 0 }, + new Object[] { IllegalArgumentException.class, ValueType.UINT16, shortArrayToRegisterArray(4, -1004), + 2 }, + // + // INT32 + // + new Object[] { new DecimalType("1.0"), ValueType.INT32, shortArrayToRegisterArray(0, 1), 0 }, + new Object[] { new DecimalType("2.0"), ValueType.INT32, shortArrayToRegisterArray(0, 2), 0 }, + new Object[] { new DecimalType("-1004"), ValueType.INT32, + // -1004 = 0xFFFFFC14 (32bit) = + shortArrayToRegisterArray(0xFFFF, 0xFC14), 0 }, + new Object[] { new DecimalType("64000"), ValueType.INT32, shortArrayToRegisterArray(0, 64000), 0 }, + new Object[] { new DecimalType("-1004"), ValueType.INT32, + // -1004 = 0xFFFFFC14 (32bit) = + shortArrayToRegisterArray(0x4, 0xFFFF, 0xFC14), 1 }, + new Object[] { new DecimalType("-1004"), ValueType.INT32, + // -1004 = 0xFFFFFC14 (32bit) = + shortArrayToRegisterArray(0xFFFF, 0xFC14, 0x4), 0 }, + new Object[] { IllegalArgumentException.class, ValueType.INT32, shortArrayToRegisterArray(4, -1004), + 1 }, + new Object[] { IllegalArgumentException.class, ValueType.INT32, shortArrayToRegisterArray(4, -1004), + 2 }, + new Object[] { IllegalArgumentException.class, ValueType.INT32, shortArrayToRegisterArray(0, 0, 0), 2 }, + // + // UINT32 + // + new Object[] { new DecimalType("1.0"), ValueType.UINT32, shortArrayToRegisterArray(0, 1), 0 }, + new Object[] { new DecimalType("2.0"), ValueType.UINT32, shortArrayToRegisterArray(0, 2), 0 }, + new Object[] { new DecimalType("4294966292"), ValueType.UINT32, + // 4294966292 = 0xFFFFFC14 (32bit) = + shortArrayToRegisterArray(0xFFFF, 0xFC14), 0 }, + new Object[] { new DecimalType("64000"), ValueType.UINT32, shortArrayToRegisterArray(0, 64000), 0 }, + new Object[] { + // out of bounds of unsigned 16bit (0 to 65,535) + new DecimalType("70004"), + // 70004 -> 0x00011174 (32bit) -> 0x1174 (16bit) + ValueType.UINT32, shortArrayToRegisterArray(1, 4468), 0 }, + new Object[] { new DecimalType("4294966292"), ValueType.UINT32, + // 4294966292 = 0xFFFFFC14 (32bit) = + shortArrayToRegisterArray(0xFFFF, 0xFC14, 0x5), 0 }, + new Object[] { new DecimalType("4294966292"), ValueType.UINT32, + // 4294966292 = 0xFFFFFC14 (32bit) = + shortArrayToRegisterArray(0x5, 0xFFFF, 0xFC14), 1 }, + new Object[] { IllegalArgumentException.class, ValueType.UINT32, shortArrayToRegisterArray(4, -1004), + 1 }, + new Object[] { IllegalArgumentException.class, ValueType.UINT32, shortArrayToRegisterArray(4, -1004), + 2 }, + new Object[] { IllegalArgumentException.class, ValueType.UINT32, shortArrayToRegisterArray(0, 0, 0), + 2 }, + // + // INT32_SWAP + // + new Object[] { new DecimalType("1.0"), ValueType.INT32_SWAP, shortArrayToRegisterArray(1, 0), 0 }, + new Object[] { new DecimalType("2.0"), ValueType.INT32_SWAP, shortArrayToRegisterArray(2, 0), 0 }, + new Object[] { new DecimalType("-1004"), ValueType.INT32_SWAP, + // -1004 = 0xFFFFFC14 (32bit) = + shortArrayToRegisterArray(0xFC14, 0xFFFF), 0 }, + new Object[] { new DecimalType("64000"), ValueType.INT32_SWAP, shortArrayToRegisterArray(64000, 0), 0 }, + new Object[] { new DecimalType("-1004"), ValueType.INT32_SWAP, + // -1004 = 0xFFFFFC14 (32bit) = + shortArrayToRegisterArray(0x4, 0xFC14, 0xFFFF), 1 }, + new Object[] { new DecimalType("-1004"), ValueType.INT32_SWAP, + // -1004 = 0xFFFFFC14 (32bit) = + shortArrayToRegisterArray(0xFC14, 0xFFFF, 0x4), 0 }, + new Object[] { IllegalArgumentException.class, ValueType.INT32_SWAP, + shortArrayToRegisterArray(4, -1004), 1 }, + new Object[] { IllegalArgumentException.class, ValueType.INT32_SWAP, + shortArrayToRegisterArray(4, -1004), 2 }, + new Object[] { IllegalArgumentException.class, ValueType.INT32_SWAP, shortArrayToRegisterArray(0, 0, 0), + 2 }, + // + // UINT32_SWAP + // + new Object[] { new DecimalType("1.0"), ValueType.UINT32_SWAP, shortArrayToRegisterArray(1, 0), 0 }, + new Object[] { new DecimalType("2.0"), ValueType.UINT32_SWAP, shortArrayToRegisterArray(2, 0), 0 }, + new Object[] { new DecimalType("4294966292"), ValueType.UINT32_SWAP, + // 4294966292 = 0xFFFFFC14 (32bit) = + shortArrayToRegisterArray(0xFC14, 0xFFFF), 0 }, + new Object[] { new DecimalType("64000"), ValueType.UINT32_SWAP, shortArrayToRegisterArray(64000, 0), + 0 }, + new Object[] { + // out of bounds of unsigned 16bit (0 to 65,535) + new DecimalType("70004"), + // 70004 -> 0x00011174 (32bit) -> 0x1174 (16bit) + ValueType.UINT32_SWAP, shortArrayToRegisterArray(4468, 1), 0 }, + new Object[] { new DecimalType("4294966292"), ValueType.UINT32_SWAP, + // 4294966292 = 0xFFFFFC14 (32bit) = + shortArrayToRegisterArray(0xFC14, 0xFFFF, 0x5), 0 }, + new Object[] { new DecimalType("4294966292"), ValueType.UINT32_SWAP, + // 4294966292 = 0xFFFFFC14 (32bit) = + shortArrayToRegisterArray(0x5, 0xFC14, 0xFFFF), 1 }, + new Object[] { IllegalArgumentException.class, ValueType.UINT32_SWAP, + shortArrayToRegisterArray(4, -1004), 1 }, + new Object[] { IllegalArgumentException.class, ValueType.UINT32_SWAP, + shortArrayToRegisterArray(4, -1004), 2 }, + new Object[] { IllegalArgumentException.class, ValueType.UINT32_SWAP, + shortArrayToRegisterArray(0, 0, 0), 2 }, + // + // FLOAT32 + // + new Object[] { new DecimalType("1.0"), ValueType.FLOAT32, shortArrayToRegisterArray(0x3F80, 0x0000), + 0 }, + new Object[] { new DecimalType(1.6f), ValueType.FLOAT32, shortArrayToRegisterArray(0x3FCC, 0xCCCD), 0 }, + new Object[] { new DecimalType(2.6f), ValueType.FLOAT32, shortArrayToRegisterArray(0x4026, 0x6666), 0 }, + new Object[] { new DecimalType(-1004.4f), ValueType.FLOAT32, shortArrayToRegisterArray(0xC47B, 0x199A), + 0 }, + new Object[] { new DecimalType("64000"), ValueType.FLOAT32, shortArrayToRegisterArray(0x477A, 0x0000), + 0 }, + new Object[] { + // out of bounds of unsigned 16bit (0 to 65,535) + new DecimalType(70004.4f), ValueType.FLOAT32, shortArrayToRegisterArray(0x4788, 0xBA33), 0 }, + new Object[] { + // out of bounds of unsigned 32bit (0 to 4,294,967,295) + new DecimalType("5000000000"), ValueType.FLOAT32, shortArrayToRegisterArray(0x4F95, 0x02F9), + 0 }, + new Object[] { new DecimalType(-1004.4f), ValueType.FLOAT32, + shortArrayToRegisterArray(0x4, 0xC47B, 0x199A), 1 }, + new Object[] { new DecimalType(-1004.4f), ValueType.FLOAT32, + shortArrayToRegisterArray(0xC47B, 0x199A, 0x4), 0 }, + new Object[] { // equivalent of NaN + Optional.empty(), ValueType.FLOAT32, shortArrayToRegisterArray(0x7fc0, 0x0000), 0 }, + new Object[] { new DecimalType(-1004.4f), ValueType.FLOAT32, + shortArrayToRegisterArray(0x4, 0x0, 0x0, 0x0, 0xC47B, 0x199A), 4 }, + new Object[] { IllegalArgumentException.class, ValueType.FLOAT32, shortArrayToRegisterArray(4, -1004), + 1 }, + new Object[] { IllegalArgumentException.class, ValueType.FLOAT32, shortArrayToRegisterArray(4, -1004), + 2 }, + new Object[] { IllegalArgumentException.class, ValueType.FLOAT32, shortArrayToRegisterArray(0, 0, 0), + 2 }, + // + // FLOAT32_SWAP + // + new Object[] { new DecimalType("1.0"), ValueType.FLOAT32_SWAP, + shortArrayToRegisterArray(0x0000, 0x3F80), 0 }, + new Object[] { new DecimalType(1.6f), ValueType.FLOAT32_SWAP, shortArrayToRegisterArray(0xCCCD, 0x3FCC), + 0 }, + new Object[] { new DecimalType(2.6f), ValueType.FLOAT32_SWAP, shortArrayToRegisterArray(0x6666, 0x4026), + 0 }, + new Object[] { new DecimalType(-1004.4f), ValueType.FLOAT32_SWAP, + shortArrayToRegisterArray(0x199A, 0xC47B), 0 }, + new Object[] { new DecimalType("64000"), ValueType.FLOAT32_SWAP, + shortArrayToRegisterArray(0x0000, 0x477A), 0 }, + new Object[] { // equivalent of NaN + Optional.empty(), ValueType.FLOAT32_SWAP, shortArrayToRegisterArray(0x0000, 0x7fc0), 0 }, + new Object[] { + // out of bounds of unsigned 16bit (0 to 65,535) + new DecimalType(70004.4f), ValueType.FLOAT32_SWAP, shortArrayToRegisterArray(0xBA33, 0x4788), + 0 }, + new Object[] { + // out of bounds of unsigned 32bit (0 to 4,294,967,295) + new DecimalType("5000000000"), ValueType.FLOAT32_SWAP, + shortArrayToRegisterArray(0x02F9, 0x4F95), 0 }, + new Object[] { new DecimalType(-1004.4f), ValueType.FLOAT32_SWAP, + shortArrayToRegisterArray(0x4, 0x199A, 0xC47B), 1 }, + new Object[] { new DecimalType(-1004.4f), ValueType.FLOAT32_SWAP, + shortArrayToRegisterArray(0x199A, 0xC47B, 0x4), 0 }, + new Object[] { IllegalArgumentException.class, ValueType.FLOAT32_SWAP, + shortArrayToRegisterArray(4, -1004), 1 }, + new Object[] { IllegalArgumentException.class, ValueType.FLOAT32_SWAP, + shortArrayToRegisterArray(4, -1004), 2 }, + new Object[] { IllegalArgumentException.class, ValueType.FLOAT32_SWAP, + shortArrayToRegisterArray(0, 0, 0), 2 }, + + // + // INT64 + // + new Object[] { new DecimalType("1.0"), ValueType.INT64, shortArrayToRegisterArray(0, 0, 0, 1), 0 }, + new Object[] { new DecimalType("2.0"), ValueType.INT64, shortArrayToRegisterArray(0, 0, 0, 2), 0 }, + new Object[] { new DecimalType("-1004"), ValueType.INT64, + shortArrayToRegisterArray(0xFFFF, 0xFFFF, 0xFFFF, 0xFC14), 0 }, + new Object[] { new DecimalType("64000"), ValueType.INT64, shortArrayToRegisterArray(0, 0, 0, 64000), + 0 }, + new Object[] { + // out of bounds of unsigned 32bit + new DecimalType("34359738368"), ValueType.INT64, shortArrayToRegisterArray(0x0, 0x8, 0x0, 0x0), + 0 }, + new Object[] { new DecimalType("-2322243636186679031"), ValueType.INT64, + shortArrayToRegisterArray(0xDFC5, 0xBBB7, 0x772E, 0x7909), 0 }, + // would read over the registers + new Object[] { IllegalArgumentException.class, ValueType.INT64, + shortArrayToRegisterArray(0xDFC5, 0xBBB7, 0x772E, 0x7909), 1 }, + // would read over the registers + new Object[] { IllegalArgumentException.class, ValueType.INT64, + shortArrayToRegisterArray(0xDFC5, 0xBBB7, 0x772E, 0x7909), 2 }, + // 4 registers expected, only 3 available + new Object[] { IllegalArgumentException.class, ValueType.INT64, + shortArrayToRegisterArray(0xDFC5, 0xBBB7, 0x772E), 0 }, + + // + // UINT64 + // + new Object[] { new DecimalType("1.0"), ValueType.UINT64, shortArrayToRegisterArray(0, 0, 0, 1), 0 }, + new Object[] { new DecimalType("2.0"), ValueType.UINT64, shortArrayToRegisterArray(0, 0, 0, 2), 0 }, + new Object[] { new DecimalType("18446744073709550612"), ValueType.UINT64, + shortArrayToRegisterArray(0xFFFF, 0xFFFF, 0xFFFF, 0xFC14), 0 }, + new Object[] { new DecimalType("64000"), ValueType.UINT64, shortArrayToRegisterArray(0, 0, 0, 64000), + 0 }, + new Object[] { + // out of bounds of unsigned 32bit + new DecimalType("34359738368"), ValueType.UINT64, shortArrayToRegisterArray(0x0, 0x8, 0x0, 0x0), + 0 }, + new Object[] { new DecimalType("16124500437522872585"), ValueType.UINT64, + shortArrayToRegisterArray(0xDFC5, 0xBBB7, 0x772E, 0x7909), 0 }, + + // + // INT64_SWAP + // + new Object[] { new DecimalType("1.0"), ValueType.INT64_SWAP, shortArrayToRegisterArray(1, 0, 0, 0), 0 }, + new Object[] { new DecimalType("2.0"), ValueType.INT64_SWAP, shortArrayToRegisterArray(2, 0, 0, 0), 0 }, + new Object[] { new DecimalType("-1004"), ValueType.INT64_SWAP, + shortArrayToRegisterArray(0xFC14, 0xFFFF, 0xFFFF, 0xFFFF), 0 }, + new Object[] { new DecimalType("64000"), ValueType.INT64_SWAP, + shortArrayToRegisterArray(64000, 0, 0, 0), 0 }, + new Object[] { + // out of bounds of unsigned 32bit + new DecimalType("34359738368"), + // 70004 -> 0x00011174 (32bit) -> 0x1174 (16bit) + ValueType.INT64_SWAP, shortArrayToRegisterArray(0x0, 0x0, 0x8, 0x0), 0 }, + new Object[] { new DecimalType("-2322243636186679031"), ValueType.INT64_SWAP, + + shortArrayToRegisterArray(0x7909, 0x772E, 0xBBB7, 0xDFC5), 0 }, + + // + // UINT64_SWAP + // + new Object[] { new DecimalType("1.0"), ValueType.UINT64_SWAP, shortArrayToRegisterArray(1, 0, 0, 0), + 0 }, + new Object[] { new DecimalType("2.0"), ValueType.UINT64_SWAP, shortArrayToRegisterArray(2, 0, 0, 0), + 0 }, + new Object[] { new DecimalType("18446744073709550612"), ValueType.UINT64_SWAP, + shortArrayToRegisterArray(0xFC14, 0xFFFF, 0xFFFF, 0xFFFF), 0 }, + new Object[] { new DecimalType("64000"), ValueType.UINT64_SWAP, + shortArrayToRegisterArray(64000, 0, 0, 0), 0 }, + new Object[] { + // out of bounds of unsigned 32bit + new DecimalType("34359738368"), ValueType.UINT64_SWAP, + shortArrayToRegisterArray(0x0, 0x0, 0x8, 0x0), 0 }, + new Object[] { + // out of bounds of unsigned 64bit + new DecimalType("16124500437522872585"), ValueType.UINT64_SWAP, + shortArrayToRegisterArray(0x7909, 0x772E, 0xBBB7, 0xDFC5), 0 }); + } + + @SuppressWarnings("unchecked") + @ParameterizedTest + @MethodSource("data") + public void testextractStateFromRegisters(Object expectedResult, ValueType type, ModbusRegisterArray registers, + int index) { + if (expectedResult instanceof Class class1 && Exception.class.isAssignableFrom(class1)) { + assertThrows(class1, () -> ModbusBitUtilities.extractStateFromRegisters(registers, index, type)); + return; + } + + Optional actualState = ModbusBitUtilities.extractStateFromRegisters(registers, index, type); + // Wrap given expectedResult to Optional, if necessary + Optional expectedStateWrapped = expectedResult instanceof DecimalType dt ? Optional.of(dt) + : (Optional) expectedResult; + assertThat(String.format("registers=%s, index=%d, type=%s", registers, index, type), actualState, + is(equalTo(expectedStateWrapped))); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesExtractStringTest.java b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesExtractStringTest.java new file mode 100644 index 00000000000..c73e7187455 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesExtractStringTest.java @@ -0,0 +1,133 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.test; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Collection; +import java.util.List; +import java.util.stream.Stream; +import java.util.stream.Stream.Builder; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.openhab.core.io.transport.sbus.ModbusBitUtilities; +import org.openhab.core.io.transport.sbus.ModbusRegisterArray; + +/** + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public class BitUtilitiesExtractStringTest { + + private static ModbusRegisterArray shortArrayToRegisterArray(int... arr) { + return new ModbusRegisterArray(arr); + } + + public static Collection data() { + return List.of(new Object[] { "", shortArrayToRegisterArray(0), 0, 0, StandardCharsets.UTF_8 }, + new Object[] { "hello", shortArrayToRegisterArray(0x6865, 0x6c6c, 0x6f00), 0, 5, + StandardCharsets.UTF_8 }, + new Object[] { "he", shortArrayToRegisterArray(0x6865, 0x6c6c, 0x6f00), 0, 2, StandardCharsets.UTF_8 }, // limited + // by + // count=2 + new Object[] { "hello ", shortArrayToRegisterArray(0, 0, 0x6865, 0x6c6c, 0x6f20, 0, 0), 2, 6, + StandardCharsets.UTF_8 }, + new Object[] { "hello", shortArrayToRegisterArray(0x6865, 0x6c6c, 0x6f00, 0x0000, 0x0000), 0, 10, + StandardCharsets.UTF_8 }, + new Object[] { "árvíztűrő tükörfúrógép", + shortArrayToRegisterArray(0xc3a1, 0x7276, 0xc3ad, 0x7a74, 0xc5b1, 0x72c5, 0x9120, 0x74c3, + 0xbc6b, 0xc3b6, 0x7266, 0xc3ba, 0x72c3, 0xb367, 0xc3a9, 0x7000), + 0, 32, StandardCharsets.UTF_8 }, + new Object[] { "你好,世界", + shortArrayToRegisterArray(0xe4bd, 0xa0e5, 0xa5bd, 0xefbc, 0x8ce4, 0xb896, 0xe795, 0x8c00), 0, + 16, StandardCharsets.UTF_8 }, + new Object[] { "árvíztűrő tükörfúrógép", + shortArrayToRegisterArray(0xe172, 0x76ed, 0x7a74, 0xfb72, 0xf520, 0x74fc, 0x6bf6, 0x7266, + 0xfa72, 0xf367, 0xe970), + 0, 22, Charset.forName("ISO-8859-2") }, + // Example where registers contain 0 byte in between -- only the data preceding zero byte is parsed + new Object[] { "hello", shortArrayToRegisterArray(0x6865, 0x6c6c, 0x6f00, 0x776f, 0x726c, 0x64), 0, 10, + StandardCharsets.UTF_8 }, + + // Invalid values + // 0xe4 = "ä" in extended ascii but not covered by US_ASCII. Will be replaced by � + new Object[] { "�", shortArrayToRegisterArray(0xe400), 0, 2, StandardCharsets.US_ASCII }, + // out of bounds + new Object[] { IllegalArgumentException.class, shortArrayToRegisterArray(0, 0), 2, 4, + StandardCharsets.UTF_8 }, + // negative index + new Object[] { IllegalArgumentException.class, shortArrayToRegisterArray(0, 0), 0, -1, + StandardCharsets.UTF_8 }, + // out of bounds + new Object[] { IllegalArgumentException.class, shortArrayToRegisterArray(0, 0), 0, 5, + StandardCharsets.UTF_8 }); + } + + public static Stream dataWithByteVariations() { + return data().stream().flatMap(vals -> { + Object expected = vals[0]; + ModbusRegisterArray registers = (ModbusRegisterArray) vals[1]; + int index = (int) vals[2]; + int length = (int) vals[3]; + Charset charset = (Charset) vals[4]; + + byte[] origBytes = registers.getBytes(); + int origRegisterIndex = index; + int origByteIndex = origRegisterIndex * 2; + + Builder streamBuilder = Stream.builder(); + for (int offset = 0; offset < 5; offset++) { + byte[] bytesOffsetted = new byte[origBytes.length + offset]; + System.arraycopy(origBytes, 0, bytesOffsetted, offset, origBytes.length); + streamBuilder.add( + new Object[] { expected, offset, bytesOffsetted, origByteIndex + offset, length, charset }); + } + return streamBuilder.build(); + }); + } + + @SuppressWarnings("unchecked") + @ParameterizedTest + @MethodSource("data") + public void testExtractStringFromRegisters(Object expectedResult, ModbusRegisterArray registers, int index, + int length, Charset charset) { + if (expectedResult instanceof Class class1 && Exception.class.isAssignableFrom(class1)) { + assertThrows(class1, + () -> ModbusBitUtilities.extractStringFromRegisters(registers, index, length, charset)); + return; + } else { + String actualState = ModbusBitUtilities.extractStringFromRegisters(registers, index, length, charset); + assertEquals(actualState, expectedResult, + String.format("registers=%s, index=%d, length=%d", registers, index, length)); + } + } + + @SuppressWarnings("unchecked") + @ParameterizedTest + @MethodSource("dataWithByteVariations") + public void testExtractStringFromBytes(Object expectedResult, int byteOffset, byte[] bytes, int byteIndex, + int length, Charset charset) { + if (expectedResult instanceof Class class1 && Exception.class.isAssignableFrom(class1)) { + assertThrows(class1, () -> ModbusBitUtilities.extractStringFromBytes(bytes, byteIndex, length, charset)); + return; + } else { + String actualState = ModbusBitUtilities.extractStringFromBytes(bytes, byteIndex, length, charset); + assertEquals(actualState, expectedResult, String.format("registers=%s, index=%d, length=%d, byteIndex=%d", + bytes, byteIndex, length, byteIndex)); + } + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesTranslateCommand2BooleanTest.java b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesTranslateCommand2BooleanTest.java new file mode 100644 index 00000000000..8738d8ca6d8 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/BitUtilitiesTranslateCommand2BooleanTest.java @@ -0,0 +1,81 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.test; + +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.assertThat; + +import java.util.Optional; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.core.io.transport.sbus.ModbusBitUtilities; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.IncreaseDecreaseType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.OpenClosedType; + +/** + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public class BitUtilitiesTranslateCommand2BooleanTest { + + @Test + public void testZero() { + Optional actual = ModbusBitUtilities.translateCommand2Boolean(DecimalType.ZERO); + assertThat(actual, is(equalTo(Optional.of(false)))); + } + + @Test + public void testNegative() { + Optional actual = ModbusBitUtilities.translateCommand2Boolean(new DecimalType(-3.4)); + assertThat(actual, is(equalTo(Optional.of(true)))); + } + + @Test + public void testPositive() { + Optional actual = ModbusBitUtilities.translateCommand2Boolean(new DecimalType(3.4)); + assertThat(actual, is(equalTo(Optional.of(true)))); + } + + @Test + public void testOn() { + Optional actual = ModbusBitUtilities.translateCommand2Boolean(OnOffType.ON); + assertThat(actual, is(equalTo(Optional.of(true)))); + } + + @Test + public void testOpen() { + Optional actual = ModbusBitUtilities.translateCommand2Boolean(OpenClosedType.OPEN); + assertThat(actual, is(equalTo(Optional.of(true)))); + } + + @Test + public void testOff() { + Optional actual = ModbusBitUtilities.translateCommand2Boolean(OnOffType.OFF); + assertThat(actual, is(equalTo(Optional.of(false)))); + } + + @Test + public void testClosed() { + Optional actual = ModbusBitUtilities.translateCommand2Boolean(OpenClosedType.CLOSED); + assertThat(actual, is(equalTo(Optional.of(false)))); + } + + @Test + public void testUnknown() { + Optional actual = ModbusBitUtilities.translateCommand2Boolean(IncreaseDecreaseType.INCREASE); + assertThat(actual, is(equalTo(Optional.empty()))); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/CoilMatcher.java b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/CoilMatcher.java new file mode 100644 index 00000000000..4ab69ad7fc4 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/CoilMatcher.java @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.test; + +import java.util.Arrays; +import java.util.Objects; +import java.util.stream.StreamSupport; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.hamcrest.Description; +import org.openhab.core.io.transport.sbus.ModbusWriteCoilRequestBlueprint; +import org.openhab.core.io.transport.sbus.ModbusWriteFunctionCode; + +/** + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +class CoilMatcher extends AbstractRequestComparer { + + private Boolean[] expectedCoils; + + public CoilMatcher(int expectedUnitId, int expectedAddress, int expectedMaxTries, + ModbusWriteFunctionCode expectedFunctionCode, Boolean... expectedCoils) { + super(expectedUnitId, expectedAddress, expectedFunctionCode, expectedMaxTries); + this.expectedCoils = expectedCoils; + } + + @Override + public void describeTo(@NonNullByDefault({}) Description description) { + super.describeTo(description); + description.appendText(" coils="); + description.appendValue(Arrays.toString(expectedCoils)); + } + + @Override + protected boolean doMatchData(ModbusWriteCoilRequestBlueprint item) { + Object[] actual = StreamSupport.stream(item.getCoils().spliterator(), false).toArray(); + return Objects.deepEquals(actual, expectedCoils); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/IntegrationTestSupport.java b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/IntegrationTestSupport.java new file mode 100644 index 00000000000..35a96afc228 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/IntegrationTestSupport.java @@ -0,0 +1,354 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.test; + +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.io.UncheckedIOException; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.Socket; +import java.net.SocketException; +import java.net.UnknownHostException; +import java.util.HashMap; +import java.util.function.LongSupplier; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.openhab.core.io.transport.sbus.endpoint.ModbusSlaveEndpoint; +import org.openhab.core.io.transport.sbus.endpoint.ModbusTCPSlaveEndpoint; +import org.openhab.core.io.transport.sbus.internal.ModbusManagerImpl; +import org.openhab.core.test.java.JavaTest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import gnu.io.SerialPort; +import ro.ciprianpascu.sbus.Modbus; +import ro.ciprianpascu.sbus.ModbusCoupler; +import ro.ciprianpascu.sbus.io.ModbusTransport; +import ro.ciprianpascu.sbus.msg.ModbusRequest; +import ro.ciprianpascu.sbus.net.ModbusSerialListener; +import ro.ciprianpascu.sbus.net.ModbusTCPListener; +import ro.ciprianpascu.sbus.net.ModbusUDPListener; +import ro.ciprianpascu.sbus.net.SerialConnection; +import ro.ciprianpascu.sbus.net.SerialConnectionFactory; +import ro.ciprianpascu.sbus.net.TCPSlaveConnection; +import ro.ciprianpascu.sbus.net.TCPSlaveConnection.ModbusTCPTransportFactory; +import ro.ciprianpascu.sbus.net.TCPSlaveConnectionFactory; +import ro.ciprianpascu.sbus.net.UDPSlaveTerminal; +import ro.ciprianpascu.sbus.net.UDPSlaveTerminal.ModbusUDPTransportFactoryImpl; +import ro.ciprianpascu.sbus.net.UDPSlaveTerminalFactory; +import ro.ciprianpascu.sbus.net.UDPTerminal; +import ro.ciprianpascu.sbus.procimg.SimpleProcessImage; +import ro.ciprianpascu.sbus.util.AtomicCounter; +import ro.ciprianpascu.sbus.util.SerialParameters; + +/** + * @author Sami Salonen - Initial contribution + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@NonNullByDefault +public class IntegrationTestSupport extends JavaTest { + + private final Logger logger = LoggerFactory.getLogger(IntegrationTestSupport.class); + + public enum ServerType { + TCP, + UDP, + SERIAL + } + + /** + * Servers to test + * Serial is system dependent + */ + public static final ServerType[] TEST_SERVERS = new ServerType[] { ServerType.TCP + // ServerType.UDP, + // ServerType.SERIAL + }; + + // One can perhaps test SERIAL with https://github.com/freemed/tty0tty + // and using those virtual ports? Not the same thing as real serial device of course + private static final String SERIAL_SERVER_PORT = "/dev/pts/7"; + private static final String SERIAL_CLIENT_PORT = "/dev/pts/8"; + + private static final SerialParameters SERIAL_PARAMETERS_CLIENT = new SerialParameters(SERIAL_CLIENT_PORT, 115200, + SerialPort.FLOWCONTROL_NONE, SerialPort.FLOWCONTROL_NONE, SerialPort.DATABITS_8, SerialPort.STOPBITS_1, + SerialPort.PARITY_NONE, Modbus.SERIAL_ENCODING_ASCII, false, 1000); + + private static final SerialParameters SERIAL_PARAMETERS_SERVER = new SerialParameters(SERIAL_SERVER_PORT, + SERIAL_PARAMETERS_CLIENT.getBaudRate(), SERIAL_PARAMETERS_CLIENT.getFlowControlIn(), + SERIAL_PARAMETERS_CLIENT.getFlowControlOut(), SERIAL_PARAMETERS_CLIENT.getDatabits(), + SERIAL_PARAMETERS_CLIENT.getStopbits(), SERIAL_PARAMETERS_CLIENT.getParity(), + SERIAL_PARAMETERS_CLIENT.getEncoding(), SERIAL_PARAMETERS_CLIENT.isEcho(), 1000); + + static { + System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "trace"); + System.setProperty("gnu.io.rxtx.SerialPorts", SERIAL_SERVER_PORT + File.pathSeparator + SERIAL_CLIENT_PORT); + } + + /** + * Max time to wait for connections/requests from client + */ + protected static final int MAX_WAIT_REQUESTS_MILLIS = 1000; + + /** + * The server runs in single thread, only one connection is accepted at a time. + * This makes the tests as strict as possible -- connection must be closed. + */ + private static final int SERVER_THREADS = 1; + protected static final int SLAVE_SUBNET_ID = 1; + protected static final int SLAVE_UNIT_ID = 1; + + private static AtomicCounter udpServerIndex = new AtomicCounter(0); + + protected @Spy TCPSlaveConnectionFactory tcpConnectionFactory = new TCPSlaveConnectionFactoryImpl(); + protected @Spy UDPSlaveTerminalFactory udpTerminalFactory = new UDPSlaveTerminalFactoryImpl(); + protected @Spy SerialConnectionFactory serialConnectionFactory = new SerialConnectionFactoryImpl(); + + protected @NonNullByDefault({}) ResultCaptor modbustRequestCaptor; + + protected @NonNullByDefault({}) ModbusTCPListener tcpListener; + protected @NonNullByDefault({}) ModbusUDPListener udpListener; + protected @NonNullByDefault({}) ModbusSerialListener serialListener; + protected @NonNullByDefault({}) SimpleProcessImage spi; + protected int tcpModbusPort = -1; + protected int udpModbusPort = -1; + protected ServerType serverType = ServerType.TCP; + protected long artificialServerWait = 0; + + protected @NonNullByDefault({}) NonOSGIModbusManager modbusManager; + + private Thread serialServerThread = new Thread("ModbusTransportTestsSerialServer") { + @Override + public void run() { + serialListener = new ModbusSerialListener(SERIAL_PARAMETERS_SERVER); + } + }; + + protected static InetAddress localAddress() { + try { + return InetAddress.getByName("127.0.0.1"); + } catch (UnknownHostException e) { + throw new UncheckedIOException(e); + } + } + + @BeforeEach + public void setUp() throws Exception { + modbustRequestCaptor = new ResultCaptor<>(new LongSupplier() { + + @Override + public long getAsLong() { + return artificialServerWait; + } + }); + modbusManager = new NonOSGIModbusManager(); + startServer(); + } + + @AfterEach + public void tearDown() { + stopServer(); + modbusManager.close(); + } + + protected void waitForRequests(int expectedRequestCount) { + waitForAssert( + () -> assertThat(modbustRequestCaptor.getAllReturnValues().size(), is(equalTo(expectedRequestCount))), + MAX_WAIT_REQUESTS_MILLIS, 10); + } + + protected void waitForConnectionsReceived(int expectedConnections) { + waitForAssert(() -> { + if (ServerType.TCP.equals(serverType)) { + verify(tcpConnectionFactory, times(expectedConnections)).create(any(Socket.class)); + } else if (ServerType.UDP.equals(serverType)) { + logger.debug("No-op, UDP server type"); + } else if (ServerType.SERIAL.equals(serverType)) { + logger.debug("No-op, SERIAL server type"); + } else { + throw new UnsupportedOperationException(); + } + }, MAX_WAIT_REQUESTS_MILLIS, 10); + } + + private void startServer() { + spi = new SimpleProcessImage(); + ModbusCoupler.getReference().setProcessImage(spi); + ModbusCoupler.getReference().setMaster(false); + ModbusCoupler.getReference().setSubnetID(SLAVE_SUBNET_ID); + ModbusCoupler.getReference().setUnitID(SLAVE_UNIT_ID); + + if (ServerType.TCP.equals(serverType)) { + startTCPServer(); + } else if (ServerType.UDP.equals(serverType)) { + startUDPServer(); + } else if (ServerType.SERIAL.equals(serverType)) { + startSerialServer(); + } else { + throw new UnsupportedOperationException(); + } + } + + private void stopServer() { + if (ServerType.TCP.equals(serverType)) { + tcpListener.stop(); + logger.debug("Stopped TCP listener, tcpModbusPort={}", tcpModbusPort); + } else if (ServerType.UDP.equals(serverType)) { + udpListener.stop(); + logger.debug("Stopped UDP listener, udpModbusPort={}", udpModbusPort); + } else if (ServerType.SERIAL.equals(serverType)) { + try { + serialServerThread.join(100); + } catch (InterruptedException e) { + logger.debug("Serial server thread .join() interrupted! Will interrupt it now."); + } + serialServerThread.interrupt(); + } else { + throw new UnsupportedOperationException(); + } + } + + private void startUDPServer() { + udpListener = new ModbusUDPListener(localAddress(), udpTerminalFactory); + for (int portCandidate = 10000 + udpServerIndex.increment(); portCandidate < 20000; portCandidate++) { + try { + DatagramSocket socket = new DatagramSocket(portCandidate); + socket.close(); + udpListener.setPort(portCandidate); + break; + } catch (SocketException e) { + continue; + } + } + + udpListener.start(); + waitForUDPServerStartup(); + assertNotSame(-1, udpModbusPort); + assertNotSame(0, udpModbusPort); + } + + private void waitForUDPServerStartup() { + // Query server port. It seems to take time (probably due to thread starting) + waitFor(() -> udpListener.getLocalPort() > 0, 5, 10_000); + udpModbusPort = udpListener.getLocalPort(); + } + + private void startTCPServer() { + // Serve single user at a time + tcpListener = new ModbusTCPListener(SERVER_THREADS, localAddress(), tcpConnectionFactory); + // Use any open port + tcpListener.setPort(0); + tcpListener.start(); + // Query server port. It seems to take time (probably due to thread starting) + waitForTCPServerStartup(); + assertNotSame(-1, tcpModbusPort); + assertNotSame(0, tcpModbusPort); + } + + private void waitForTCPServerStartup() { + waitFor(() -> tcpListener.getLocalPort() > 0, 10_000, 5); + tcpModbusPort = tcpListener.getLocalPort(); + } + + private void startSerialServer() { + serialServerThread.start(); + assertDoesNotThrow(() -> Thread.sleep(1000)); + } + + public ModbusSlaveEndpoint getEndpoint() { + assert tcpModbusPort > 0; + return new ModbusTCPSlaveEndpoint("127.0.0.1", tcpModbusPort, false); + } + + /** + * Transport factory that spies the created transport items + */ + public class SpyingModbusTCPTransportFactory extends ModbusTCPTransportFactory { + + @Override + public ModbusTransport create(@NonNullByDefault({}) Socket socket) { + ModbusTransport transport = spy(super.create(socket)); + // Capture requests produced by our server transport + assertDoesNotThrow(() -> doAnswer(modbustRequestCaptor).when(transport).readRequest()); + return transport; + } + } + + public class SpyingModbusUDPTransportFactory extends ModbusUDPTransportFactoryImpl { + + @Override + public ModbusTransport create(@NonNullByDefault({}) UDPTerminal terminal) { + ModbusTransport transport = spy(super.create(terminal)); + // Capture requests produced by our server transport + assertDoesNotThrow(() -> doAnswer(modbustRequestCaptor).when(transport).readRequest()); + return transport; + } + } + + public class TCPSlaveConnectionFactoryImpl implements TCPSlaveConnectionFactory { + + @Override + public TCPSlaveConnection create(@NonNullByDefault({}) Socket socket) { + return new TCPSlaveConnection(socket, new SpyingModbusTCPTransportFactory()); + } + } + + public class UDPSlaveTerminalFactoryImpl implements UDPSlaveTerminalFactory { + + @Override + public UDPSlaveTerminal create(@NonNullByDefault({}) InetAddress interfac, int port) { + UDPSlaveTerminal terminal = new UDPSlaveTerminal(interfac, new SpyingModbusUDPTransportFactory(), 1); + terminal.setLocalPort(port); + return terminal; + } + } + + public class SerialConnectionFactoryImpl implements SerialConnectionFactory { + @Override + public SerialConnection create(@NonNullByDefault({}) SerialParameters parameters) { + return new SerialConnection(parameters) { + @Override + public ModbusTransport getModbusTransport() { + ModbusTransport transport = spy(super.getModbusTransport()); + assertDoesNotThrow(() -> doAnswer(modbustRequestCaptor).when(transport).readRequest()); + return transport; + } + }; + } + } + + public static class NonOSGIModbusManager extends ModbusManagerImpl implements AutoCloseable { + public NonOSGIModbusManager() { + activate(new HashMap<>()); + } + + @Override + public void close() { + deactivate(); + } + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/ModbusSlaveEndpointTestCase.java b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/ModbusSlaveEndpointTestCase.java new file mode 100644 index 00000000000..807212aa358 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/ModbusSlaveEndpointTestCase.java @@ -0,0 +1,122 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.test; + +import static org.junit.jupiter.api.Assertions.*; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.core.io.transport.sbus.endpoint.ModbusSerialSlaveEndpoint; +import org.openhab.core.io.transport.sbus.endpoint.ModbusTCPSlaveEndpoint; +import org.openhab.core.io.transport.sbus.endpoint.ModbusUDPSlaveEndpoint; + +import gnu.io.SerialPort; +import ro.ciprianpascu.sbus.Modbus; + +/** + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public class ModbusSlaveEndpointTestCase { + + @Test + public void testEqualsSameTcp() { + ModbusTCPSlaveEndpoint e1 = new ModbusTCPSlaveEndpoint("127.0.0.1", 500, false); + ModbusTCPSlaveEndpoint e2 = new ModbusTCPSlaveEndpoint("127.0.0.1", 500, false); + assertEquals(e1, e2); + } + + @Test + public void testEqualsSameSerial2() { + ModbusSerialSlaveEndpoint e1 = new ModbusSerialSlaveEndpoint("port1", 9600, SerialPort.FLOWCONTROL_NONE, + SerialPort.FLOWCONTROL_NONE, SerialPort.DATABITS_8, SerialPort.STOPBITS_1, SerialPort.PARITY_NONE, + Modbus.DEFAULT_SERIAL_ENCODING, true, 500); + ModbusSerialSlaveEndpoint e2 = new ModbusSerialSlaveEndpoint("port1", 9600, SerialPort.FLOWCONTROL_NONE, + SerialPort.FLOWCONTROL_NONE, SerialPort.DATABITS_8, SerialPort.STOPBITS_1, SerialPort.PARITY_NONE, + Modbus.DEFAULT_SERIAL_ENCODING, true, 500); + assertEquals(e1, e2); + } + + /** + * even though different echo parameter & baud rate, the endpoints are considered the same due to same port + */ + @Test + public void testEqualsSameSerial3() { + ModbusSerialSlaveEndpoint e1 = new ModbusSerialSlaveEndpoint("port1", 9600, SerialPort.FLOWCONTROL_NONE, + SerialPort.FLOWCONTROL_NONE, SerialPort.DATABITS_8, SerialPort.STOPBITS_1, SerialPort.PARITY_NONE, + Modbus.DEFAULT_SERIAL_ENCODING, true, 500); + ModbusSerialSlaveEndpoint e2 = new ModbusSerialSlaveEndpoint("port1", 9600, SerialPort.FLOWCONTROL_NONE, + SerialPort.FLOWCONTROL_NONE, SerialPort.DATABITS_8, SerialPort.STOPBITS_1, SerialPort.PARITY_NONE, + Modbus.DEFAULT_SERIAL_ENCODING, false, 500); + assertEquals(e1, e2); + assertEquals(e1.hashCode(), e2.hashCode()); + } + + @Test + public void testEqualsDifferentSerial() { + ModbusSerialSlaveEndpoint e1 = new ModbusSerialSlaveEndpoint("port1", 9600, SerialPort.FLOWCONTROL_NONE, + SerialPort.FLOWCONTROL_NONE, SerialPort.DATABITS_8, SerialPort.STOPBITS_1, SerialPort.PARITY_NONE, + Modbus.DEFAULT_SERIAL_ENCODING, true, 500); + ModbusSerialSlaveEndpoint e2 = new ModbusSerialSlaveEndpoint("port2", 9600, SerialPort.FLOWCONTROL_NONE, + SerialPort.FLOWCONTROL_NONE, SerialPort.DATABITS_8, SerialPort.STOPBITS_1, SerialPort.PARITY_NONE, + Modbus.DEFAULT_SERIAL_ENCODING, true, 500); + assertNotEquals(e1, e2); + assertNotEquals(e1.hashCode(), e2.hashCode()); + } + + @Test + public void testEqualsDifferentTCPPort() { + ModbusTCPSlaveEndpoint e1 = new ModbusTCPSlaveEndpoint("127.0.0.1", 500, false); + ModbusTCPSlaveEndpoint e2 = new ModbusTCPSlaveEndpoint("127.0.0.1", 501, false); + assertNotEquals(e1, e2); + assertNotEquals(e1.hashCode(), e2.hashCode()); + } + + @Test + public void testEqualsDifferentTCPHost() { + ModbusTCPSlaveEndpoint e1 = new ModbusTCPSlaveEndpoint("127.0.0.1", 500, false); + ModbusTCPSlaveEndpoint e2 = new ModbusTCPSlaveEndpoint("127.0.0.2", 501, false); + assertNotEquals(e1, e2); + assertNotEquals(e1.hashCode(), e2.hashCode()); + } + + @Test + public void testEqualsDifferentProtocol() { + ModbusTCPSlaveEndpoint e1 = new ModbusTCPSlaveEndpoint("127.0.0.1", 500, false); + ModbusUDPSlaveEndpoint e2 = new ModbusUDPSlaveEndpoint("127.0.0.1", 500); + assertNotEquals(e1, e2); + assertNotEquals(e1.hashCode(), e2.hashCode()); + } + + @Test + public void testEqualsDifferentProtocol2() { + ModbusTCPSlaveEndpoint e1 = new ModbusTCPSlaveEndpoint("127.0.0.1", 500, false); + ModbusSerialSlaveEndpoint e2 = new ModbusSerialSlaveEndpoint("port2", 9600, SerialPort.FLOWCONTROL_NONE, + SerialPort.FLOWCONTROL_NONE, SerialPort.DATABITS_8, SerialPort.STOPBITS_1, SerialPort.PARITY_NONE, + Modbus.DEFAULT_SERIAL_ENCODING, true, 500); + assertNotEquals(e1, e2); + assertNotEquals(e1.hashCode(), e2.hashCode()); + } + + /* + * TCP slaves pointing to same host & port are considered equal even rtu encodinng differs. + * Thus ensures correct connection pooling and connection sharing + */ + @Test + public void testEqualsSameTcpDifferentEncoding() { + ModbusTCPSlaveEndpoint e1 = new ModbusTCPSlaveEndpoint("127.0.0.1", 500, false); + ModbusTCPSlaveEndpoint e2 = new ModbusTCPSlaveEndpoint("127.0.0.1", 500, true); + assertEquals(e1, e2); + assertEquals(e1.hashCode(), e2.hashCode()); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/ModbusSlaveErrorResponseExceptionImplTest.java b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/ModbusSlaveErrorResponseExceptionImplTest.java new file mode 100644 index 00000000000..966ee959aa2 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/ModbusSlaveErrorResponseExceptionImplTest.java @@ -0,0 +1,46 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.core.io.transport.sbus.internal.ModbusSlaveErrorResponseExceptionImpl; + +import ro.ciprianpascu.sbus.ModbusSlaveException; + +/** + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public class ModbusSlaveErrorResponseExceptionImplTest { + + @Test + public void testKnownCode1() { + assertEquals("Slave responded with error=1 (ILLEGAL_FUNCTION)", + new ModbusSlaveErrorResponseExceptionImpl(new ModbusSlaveException(1)).getMessage()); + } + + @Test + public void testKnownCode2() { + assertEquals("Slave responded with error=2 (ILLEGAL_DATA_ACCESS)", + new ModbusSlaveErrorResponseExceptionImpl(new ModbusSlaveException(2)).getMessage()); + } + + @Test + public void testUnknownCode() { + assertEquals("Slave responded with error=99 (unknown error code)", + new ModbusSlaveErrorResponseExceptionImpl(new ModbusSlaveException(99)).getMessage()); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/RegisterMatcher.java b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/RegisterMatcher.java new file mode 100644 index 00000000000..4307386b554 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/RegisterMatcher.java @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.test; + +import java.util.Arrays; +import java.util.Objects; +import java.util.stream.IntStream; +import java.util.stream.StreamSupport; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.hamcrest.Description; +import org.openhab.core.io.transport.sbus.ModbusRegisterArray; +import org.openhab.core.io.transport.sbus.ModbusWriteFunctionCode; +import org.openhab.core.io.transport.sbus.ModbusWriteRegisterRequestBlueprint; + +/** + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +class RegisterMatcher extends AbstractRequestComparer { + + private Integer[] expectedRegisterValues; + + public RegisterMatcher(int expectedUnitId, int expectedAddress, int expectedMaxTries, + ModbusWriteFunctionCode expectedFunctionCode, Integer... expectedRegisterValues) { + super(expectedUnitId, expectedAddress, expectedFunctionCode, expectedMaxTries); + this.expectedRegisterValues = expectedRegisterValues; + } + + @Override + public void describeTo(@NonNullByDefault({}) Description description) { + super.describeTo(description); + description.appendText(" registers="); + description.appendValue(Arrays.toString(expectedRegisterValues)); + } + + @Override + protected boolean doMatchData(ModbusWriteRegisterRequestBlueprint item) { + ModbusRegisterArray registers = item.getRegisters(); + Object[] actual = StreamSupport + .stream(IntStream.range(0, registers.size()).mapToObj(registers::getRegister).spliterator(), false) + .toArray(); + return Objects.deepEquals(actual, expectedRegisterValues); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/ResultCaptor.java b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/ResultCaptor.java new file mode 100644 index 00000000000..cc0ca087f70 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/ResultCaptor.java @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.test; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.LongSupplier; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +/** + * @author Sami Salonen - Initial contribution + * + * @param + */ +@NonNullByDefault +public class ResultCaptor implements Answer { + + private List results = new ArrayList<>(); + private LongSupplier longSupplier; + + public ResultCaptor(LongSupplier longSupplier) { + this.longSupplier = longSupplier; + } + + public List getAllReturnValues() { + return results; + } + + @SuppressWarnings("unchecked") + @Override + public @Nullable T answer(InvocationOnMock invocationOnMock) throws Throwable { + T result = (T) invocationOnMock.callRealMethod(); + synchronized (this.results) { + results.add(result); + } + long wait = longSupplier.getAsLong(); + if (wait > 0) { + Thread.sleep(wait); + } + return result; + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/SmokeTest.java b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/SmokeTest.java new file mode 100644 index 00000000000..9c13793d4fd --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/SmokeTest.java @@ -0,0 +1,1049 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.test; + +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.*; + +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.InetAddress; +import java.net.Socket; +import java.net.SocketImpl; +import java.net.SocketImplFactory; +import java.net.SocketOption; +import java.net.StandardSocketOptions; +import java.util.BitSet; +import java.util.Objects; +import java.util.Optional; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.openhab.core.io.transport.sbus.BitArray; +import org.openhab.core.io.transport.sbus.ModbusCommunicationInterface; +import org.openhab.core.io.transport.sbus.ModbusReadFunctionCode; +import org.openhab.core.io.transport.sbus.ModbusReadRequestBlueprint; +import org.openhab.core.io.transport.sbus.ModbusRegisterArray; +import org.openhab.core.io.transport.sbus.ModbusResponse; +import org.openhab.core.io.transport.sbus.ModbusWriteCoilRequestBlueprint; +import org.openhab.core.io.transport.sbus.PollTask; +import org.openhab.core.io.transport.sbus.endpoint.EndpointPoolConfiguration; +import org.openhab.core.io.transport.sbus.endpoint.ModbusSlaveEndpoint; +import org.openhab.core.io.transport.sbus.endpoint.ModbusTCPSlaveEndpoint; +import org.openhab.core.io.transport.sbus.exception.ModbusConnectionException; +import org.openhab.core.io.transport.sbus.exception.ModbusSlaveErrorResponseException; +import org.openhab.core.io.transport.sbus.exception.ModbusSlaveIOException; +import org.slf4j.LoggerFactory; + +import ro.ciprianpascu.sbus.msg.ModbusRequest; +import ro.ciprianpascu.sbus.msg.WriteCoilRequest; +import ro.ciprianpascu.sbus.msg.WriteMultipleCoilsRequest; +import ro.ciprianpascu.sbus.procimg.SimpleDigitalIn; +import ro.ciprianpascu.sbus.procimg.SimpleDigitalOut; +import ro.ciprianpascu.sbus.procimg.SimpleRegister; +import ro.ciprianpascu.sbus.util.BitVector; + +/** + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public class SmokeTest extends IntegrationTestSupport { + + private static final int COIL_EVERY_N_TRUE = 2; + private static final int DISCRETE_EVERY_N_TRUE = 3; + private static final int HOLDING_REGISTER_MULTIPLIER = 1; + private static final int INPUT_REGISTER_MULTIPLIER = 10; + private static final SpyingSocketFactory SOCKET_SPY = new SpyingSocketFactory(); + static { + try { + Socket.setSocketImplFactory(SOCKET_SPY); + } catch (IOException e) { + fail("Could not install socket spy in SmokeTest"); + } + } + + /** + * Whether tests are run in Continuous Integration environment, i.e. Jenkins or Travis CI + * + * Travis CI is detected using CI environment variable, see https://docs.travis-ci.com/user/environment-variables/ + * Jenkins CI is detected using JENKINS_HOME environment variable + * + * @return + */ + private boolean isRunningInCI() { + String jenkinsHome = System.getenv("JENKINS_HOME"); + return "true".equals(System.getenv("CI")) || (jenkinsHome != null && !jenkinsHome.isBlank()); + } + + private void generateData() { + for (int i = 0; i < 100; i++) { + spi.addRegister(new SimpleRegister(i * HOLDING_REGISTER_MULTIPLIER)); + spi.addInputRegister(new SimpleRegister(i * INPUT_REGISTER_MULTIPLIER)); + spi.addDigitalOut(new SimpleDigitalOut(i % COIL_EVERY_N_TRUE == 0)); + spi.addDigitalIn(new SimpleDigitalIn(i % DISCRETE_EVERY_N_TRUE == 0)); + } + } + + private void testCoilValues(BitArray bits, int offsetInBitArray) { + for (int i = 0; i < bits.size(); i++) { + boolean expected = (i + offsetInBitArray) % COIL_EVERY_N_TRUE == 0; + assertThat(String.format("i=%d, expecting %b, got %b", i, bits.getBit(i), expected), bits.getBit(i), + is(equalTo(expected))); + } + } + + private void testDiscreteValues(BitArray bits, int offsetInBitArray) { + for (int i = 0; i < bits.size(); i++) { + boolean expected = (i + offsetInBitArray) % DISCRETE_EVERY_N_TRUE == 0; + assertThat(String.format("i=%d, expecting %b, got %b", i, bits.getBit(i), expected), bits.getBit(i), + is(equalTo(expected))); + } + } + + private void testHoldingValues(ModbusRegisterArray registers, int offsetInRegisters) { + for (int i = 0; i < registers.size(); i++) { + int expected = (i + offsetInRegisters) * HOLDING_REGISTER_MULTIPLIER; + assertThat(String.format("i=%d, expecting %d, got %d", i, registers.getRegister(i), expected), + registers.getRegister(i), is(equalTo(expected))); + } + } + + private void testInputValues(ModbusRegisterArray registers, int offsetInRegisters) { + for (int i = 0; i < registers.size(); i++) { + int expected = (i + offsetInRegisters) * INPUT_REGISTER_MULTIPLIER; + assertThat(String.format("i=%d, expecting %d, got %d", i, registers.getRegister(i), expected), + registers.getRegister(i), is(equalTo(expected))); + } + } + + @BeforeEach + public void setUpSocketSpy() throws IOException { + SOCKET_SPY.sockets.clear(); + } + + /** + * Test handling of slave error responses. In this case, error code = 2, illegal data address, since no data. + * + * @throws Exception + */ + @Test + public void testSlaveReadErrorResponse() throws Exception { + ModbusSlaveEndpoint endpoint = getEndpoint(); + AtomicInteger okCount = new AtomicInteger(); + AtomicInteger errorCount = new AtomicInteger(); + CountDownLatch callbackCalled = new CountDownLatch(1); + AtomicReference lastError = new AtomicReference<>(); + try (ModbusCommunicationInterface comms = modbusManager.newModbusCommunicationInterface(endpoint, null)) { + comms.submitOneTimePoll(new ModbusReadRequestBlueprint(SLAVE_SUBNET_ID, SLAVE_UNIT_ID, + ModbusReadFunctionCode.READ_MULTIPLE_REGISTERS, 0, 5, 1), result -> { + assert result.getRegisters().isPresent(); + okCount.incrementAndGet(); + callbackCalled.countDown(); + }, failure -> { + errorCount.incrementAndGet(); + lastError.set(failure.getCause()); + callbackCalled.countDown(); + }); + assertTrue(callbackCalled.await(60, TimeUnit.SECONDS)); + + assertThat(okCount.get(), is(equalTo(0))); + assertThat(errorCount.get(), is(equalTo(1))); + assertInstanceOf(ModbusSlaveErrorResponseException.class, lastError.get(), lastError.toString()); + } + } + + /** + * Test handling of connection error responses. + * + * @throws Exception + */ + @Test + public void testSlaveConnectionError() throws Exception { + // In the test we have non-responding slave (see http://stackoverflow.com/a/904609), and we use short connection + // timeout + ModbusSlaveEndpoint endpoint = new ModbusTCPSlaveEndpoint("10.255.255.1", 9999, false); + EndpointPoolConfiguration configuration = new EndpointPoolConfiguration(); + configuration.setConnectTimeoutMillis(100); + + AtomicInteger okCount = new AtomicInteger(); + AtomicInteger errorCount = new AtomicInteger(); + CountDownLatch callbackCalled = new CountDownLatch(1); + AtomicReference lastError = new AtomicReference<>(); + try (ModbusCommunicationInterface comms = modbusManager.newModbusCommunicationInterface(endpoint, + configuration)) { + comms.submitOneTimePoll(new ModbusReadRequestBlueprint(SLAVE_SUBNET_ID, SLAVE_UNIT_ID, + ModbusReadFunctionCode.READ_MULTIPLE_REGISTERS, 0, 5, 1), result -> { + assert result.getRegisters().isPresent(); + okCount.incrementAndGet(); + callbackCalled.countDown(); + }, failure -> { + errorCount.incrementAndGet(); + lastError.set(failure.getCause()); + callbackCalled.countDown(); + }); + assertTrue(callbackCalled.await(60, TimeUnit.SECONDS)); + + assertThat(okCount.get(), is(equalTo(0))); + assertThat(errorCount.get(), is(equalTo(1))); + assertInstanceOf(ModbusConnectionException.class, lastError.get(), lastError.toString()); + } + } + + /** + * Have super slow connection response, eventually resulting as timeout (due to default timeout of 3 s in + * ro.ciprianpascu.sbus.Modbus.DEFAULT_TIMEOUT) + * + * @throws Exception + */ + @Test + public void testIOError() throws Exception { + artificialServerWait = 60000; + ModbusSlaveEndpoint endpoint = getEndpoint(); + + AtomicInteger okCount = new AtomicInteger(); + AtomicInteger errorCount = new AtomicInteger(); + CountDownLatch callbackCalled = new CountDownLatch(1); + AtomicReference lastError = new AtomicReference<>(); + try (ModbusCommunicationInterface comms = modbusManager.newModbusCommunicationInterface(endpoint, null)) { + comms.submitOneTimePoll(new ModbusReadRequestBlueprint(SLAVE_SUBNET_ID, SLAVE_UNIT_ID, + ModbusReadFunctionCode.READ_MULTIPLE_REGISTERS, 0, 5, 1), result -> { + assert result.getRegisters().isPresent(); + okCount.incrementAndGet(); + callbackCalled.countDown(); + }, failure -> { + errorCount.incrementAndGet(); + lastError.set(failure.getCause()); + callbackCalled.countDown(); + }); + assertTrue(callbackCalled.await(15, TimeUnit.SECONDS)); + assertThat(okCount.get(), is(equalTo(0))); + assertThat(lastError.toString(), errorCount.get(), is(equalTo(1))); + assertInstanceOf(ModbusSlaveIOException.class, lastError.get(), lastError.toString()); + } + } + + public void testOneOffReadWithDiscreteOrCoils(ModbusReadFunctionCode functionCode, int count) throws Exception { + assertThat(functionCode, is(anyOf(equalTo(ModbusReadFunctionCode.READ_INPUT_DISCRETES), + equalTo(ModbusReadFunctionCode.READ_COILS)))); + generateData(); + ModbusSlaveEndpoint endpoint = getEndpoint(); + + AtomicInteger unexpectedCount = new AtomicInteger(); + CountDownLatch callbackCalled = new CountDownLatch(1); + AtomicReference lastData = new AtomicReference<>(); + + final int offset = 1; + + try (ModbusCommunicationInterface comms = modbusManager.newModbusCommunicationInterface(endpoint, null)) { + comms.submitOneTimePoll( + new ModbusReadRequestBlueprint(SLAVE_SUBNET_ID, SLAVE_UNIT_ID, functionCode, offset, count, 1), + result -> { + Optional bitsOptional = result.getBits(); + if (bitsOptional.isPresent()) { + lastData.set(bitsOptional.get()); + } else { + unexpectedCount.incrementAndGet(); + } + callbackCalled.countDown(); + }, failure -> { + unexpectedCount.incrementAndGet(); + callbackCalled.countDown(); + }); + assertTrue(callbackCalled.await(60, TimeUnit.SECONDS)); + + assertThat(unexpectedCount.get(), is(equalTo(0))); + BitArray bits = (BitArray) lastData.get(); + assertThat(bits, notNullValue()); + assertThat(bits.size(), is(equalTo(count))); + if (functionCode == ModbusReadFunctionCode.READ_INPUT_DISCRETES) { + testDiscreteValues(bits, offset); + } else { + testCoilValues(bits, offset); + } + } + } + + @Test + public void testOneOffReadWithDiscrete1() throws Exception { + testOneOffReadWithDiscreteOrCoils(ModbusReadFunctionCode.READ_INPUT_DISCRETES, 1); + } + + @Test + public void testOneOffReadWithDiscrete7() throws Exception { + // less than byte + testOneOffReadWithDiscreteOrCoils(ModbusReadFunctionCode.READ_INPUT_DISCRETES, 7); + } + + @Test + public void testOneOffReadWithDiscrete8() throws Exception { + // exactly one byte + testOneOffReadWithDiscreteOrCoils(ModbusReadFunctionCode.READ_INPUT_DISCRETES, 8); + } + + @Test + public void testOneOffReadWithDiscrete13() throws Exception { + // larger than byte, less than word (16 bit) + testOneOffReadWithDiscreteOrCoils(ModbusReadFunctionCode.READ_INPUT_DISCRETES, 13); + } + + @Test + public void testOneOffReadWithDiscrete18() throws Exception { + // larger than word (16 bit) + testOneOffReadWithDiscreteOrCoils(ModbusReadFunctionCode.READ_INPUT_DISCRETES, 18); + } + + @Test + public void testOneOffReadWithCoils1() throws Exception { + testOneOffReadWithDiscreteOrCoils(ModbusReadFunctionCode.READ_COILS, 1); + } + + @Test + public void testOneOffReadWithCoils7() throws Exception { + // less than byte + testOneOffReadWithDiscreteOrCoils(ModbusReadFunctionCode.READ_COILS, 7); + } + + @Test + public void testOneOffReadWithCoils8() throws Exception { + // exactly one byte + testOneOffReadWithDiscreteOrCoils(ModbusReadFunctionCode.READ_COILS, 8); + } + + @Test + public void testOneOffReadWithCoils13() throws Exception { + // larger than byte, less than word (16 bit) + testOneOffReadWithDiscreteOrCoils(ModbusReadFunctionCode.READ_COILS, 13); + } + + @Test + public void testOneOffReadWithCoils18() throws Exception { + // larger than word (16 bit) + testOneOffReadWithDiscreteOrCoils(ModbusReadFunctionCode.READ_COILS, 18); + } + + /** + * + * @throws Exception + */ + @Test + public void testOneOffReadWithHolding() throws Exception { + generateData(); + ModbusSlaveEndpoint endpoint = getEndpoint(); + + AtomicInteger unexpectedCount = new AtomicInteger(); + CountDownLatch callbackCalled = new CountDownLatch(1); + AtomicReference lastData = new AtomicReference<>(); + + try (ModbusCommunicationInterface comms = modbusManager.newModbusCommunicationInterface(endpoint, null)) { + comms.submitOneTimePoll(new ModbusReadRequestBlueprint(SLAVE_SUBNET_ID, SLAVE_UNIT_ID, + ModbusReadFunctionCode.READ_MULTIPLE_REGISTERS, 1, 15, 1), result -> { + Optional registersOptional = result.getRegisters(); + if (registersOptional.isPresent()) { + lastData.set(registersOptional.get()); + } else { + unexpectedCount.incrementAndGet(); + } + callbackCalled.countDown(); + }, failure -> { + unexpectedCount.incrementAndGet(); + callbackCalled.countDown(); + }); + assertTrue(callbackCalled.await(60, TimeUnit.SECONDS)); + + assertThat(unexpectedCount.get(), is(equalTo(0))); + ModbusRegisterArray registers = (ModbusRegisterArray) lastData.get(); + assertThat(registers.size(), is(equalTo(15))); + testHoldingValues(registers, 1); + } + } + + /** + * + * @throws Exception + */ + @Test + public void testOneOffReadWithInput() throws Exception { + generateData(); + ModbusSlaveEndpoint endpoint = getEndpoint(); + + AtomicInteger unexpectedCount = new AtomicInteger(); + CountDownLatch callbackCalled = new CountDownLatch(1); + AtomicReference lastData = new AtomicReference<>(); + try (ModbusCommunicationInterface comms = modbusManager.newModbusCommunicationInterface(endpoint, null)) { + comms.submitOneTimePoll(new ModbusReadRequestBlueprint(SLAVE_SUBNET_ID, SLAVE_UNIT_ID, + ModbusReadFunctionCode.READ_INPUT_REGISTERS, 1, 15, 1), result -> { + Optional registersOptional = result.getRegisters(); + if (registersOptional.isPresent()) { + lastData.set(registersOptional.get()); + } else { + unexpectedCount.incrementAndGet(); + } + callbackCalled.countDown(); + }, failure -> { + unexpectedCount.incrementAndGet(); + callbackCalled.countDown(); + }); + assertTrue(callbackCalled.await(60, TimeUnit.SECONDS)); + + assertThat(unexpectedCount.get(), is(equalTo(0))); + ModbusRegisterArray registers = (ModbusRegisterArray) lastData.get(); + assertThat(registers.size(), is(equalTo(15))); + testInputValues(registers, 1); + } + } + + /** + * + * @throws Exception + */ + @Test + public void testOneOffWriteMultipleCoil() throws Exception { + LoggerFactory.getLogger(this.getClass()).error("STARTING MULTIPLE"); + generateData(); + ModbusSlaveEndpoint endpoint = getEndpoint(); + + AtomicInteger unexpectedCount = new AtomicInteger(); + AtomicReference lastData = new AtomicReference<>(); + + BitArray bits = new BitArray(true, true, false, false, true, true); + try (ModbusCommunicationInterface comms = modbusManager.newModbusCommunicationInterface(endpoint, null)) { + comms.submitOneTimeWrite( + new ModbusWriteCoilRequestBlueprint(SLAVE_SUBNET_ID, SLAVE_UNIT_ID, 3, bits, true, 1), result -> { + lastData.set(result.getResponse()); + }, failure -> { + unexpectedCount.incrementAndGet(); + }); + waitForAssert(() -> { + assertThat(unexpectedCount.get(), is(equalTo(0))); + assertThat(lastData.get(), is(notNullValue())); + + ModbusResponse response = (ModbusResponse) lastData.get(); + assertThat(response.getFunctionCode(), is(equalTo(15))); + + assertThat(modbustRequestCaptor.getAllReturnValues().size(), is(equalTo(1))); + ModbusRequest request = modbustRequestCaptor.getAllReturnValues().get(0); + assertThat(request.getFunctionCode(), is(equalTo(15))); + assertThat(((WriteMultipleCoilsRequest) request).getReference(), is(equalTo(3))); + assertThat(((WriteMultipleCoilsRequest) request).getBitCount(), is(equalTo(bits.size()))); + BitVector writeRequestCoils = ((WriteMultipleCoilsRequest) request).getCoils(); + BitArray writtenBits = new BitArray(BitSet.valueOf(writeRequestCoils.getBytes()), bits.size()); + assertThat(writtenBits, is(equalTo(bits))); + }, 6000, 10); + } + LoggerFactory.getLogger(this.getClass()).error("ENDINGMULTIPLE"); + } + + /** + * Write is out-of-bounds, slave should return error + * + * @throws Exception + */ + @Test + public void testOneOffWriteMultipleCoilError() throws Exception { + generateData(); + ModbusSlaveEndpoint endpoint = getEndpoint(); + + AtomicInteger unexpectedCount = new AtomicInteger(); + CountDownLatch callbackCalled = new CountDownLatch(1); + AtomicReference lastError = new AtomicReference<>(); + + BitArray bits = new BitArray(500); + try (ModbusCommunicationInterface comms = modbusManager.newModbusCommunicationInterface(endpoint, null)) { + comms.submitOneTimeWrite( + new ModbusWriteCoilRequestBlueprint(SLAVE_SUBNET_ID, SLAVE_UNIT_ID, 3, bits, true, 1), result -> { + unexpectedCount.incrementAndGet(); + callbackCalled.countDown(); + }, failure -> { + lastError.set(failure.getCause()); + callbackCalled.countDown(); + }); + assertTrue(callbackCalled.await(60, TimeUnit.SECONDS)); + + assertThat(unexpectedCount.get(), is(equalTo(0))); + assertInstanceOf(ModbusSlaveErrorResponseException.class, lastError.get(), lastError.toString()); + + assertThat(modbustRequestCaptor.getAllReturnValues().size(), is(equalTo(1))); + ModbusRequest request = modbustRequestCaptor.getAllReturnValues().get(0); + assertThat(request.getFunctionCode(), is(equalTo(15))); + assertThat(((WriteMultipleCoilsRequest) request).getReference(), is(equalTo(3))); + assertThat(((WriteMultipleCoilsRequest) request).getBitCount(), is(equalTo(bits.size()))); + BitVector writeRequestCoils = ((WriteMultipleCoilsRequest) request).getCoils(); + BitArray writtenBits = new BitArray(BitSet.valueOf(writeRequestCoils.getBytes()), bits.size()); + assertThat(writtenBits, is(equalTo(bits))); + } + } + + /** + * + * @throws Exception + */ + @Test + public void testOneOffWriteSingleCoil() throws Exception { + generateData(); + ModbusSlaveEndpoint endpoint = getEndpoint(); + + AtomicInteger unexpectedCount = new AtomicInteger(); + CountDownLatch callbackCalled = new CountDownLatch(1); + AtomicReference lastData = new AtomicReference<>(); + + BitArray bits = new BitArray(true); + try (ModbusCommunicationInterface comms = modbusManager.newModbusCommunicationInterface(endpoint, null)) { + comms.submitOneTimeWrite( + new ModbusWriteCoilRequestBlueprint(SLAVE_SUBNET_ID, SLAVE_UNIT_ID, 3, bits, false, 1), result -> { + lastData.set(result.getResponse()); + callbackCalled.countDown(); + }, failure -> { + unexpectedCount.incrementAndGet(); + callbackCalled.countDown(); + }); + assertTrue(callbackCalled.await(60, TimeUnit.SECONDS)); + + assertThat(unexpectedCount.get(), is(equalTo(0))); + ModbusResponse response = (ModbusResponse) lastData.get(); + assertThat(response.getFunctionCode(), is(equalTo(5))); + + assertThat(modbustRequestCaptor.getAllReturnValues().size(), is(equalTo(1))); + ModbusRequest request = modbustRequestCaptor.getAllReturnValues().get(0); + assertThat(request.getFunctionCode(), is(equalTo(5))); + assertThat(((WriteCoilRequest) request).getReference(), is(equalTo(3))); + assertThat(((WriteCoilRequest) request).getCoil(), is(equalTo(true))); + } + } + + /** + * + * Write is out-of-bounds, slave should return error + * + * @throws Exception + */ + @Test + public void testOneOffWriteSingleCoilError() throws Exception { + generateData(); + ModbusSlaveEndpoint endpoint = getEndpoint(); + + AtomicInteger unexpectedCount = new AtomicInteger(); + CountDownLatch callbackCalled = new CountDownLatch(1); + AtomicReference lastError = new AtomicReference<>(); + + BitArray bits = new BitArray(true); + try (ModbusCommunicationInterface comms = modbusManager.newModbusCommunicationInterface(endpoint, null)) { + comms.submitOneTimeWrite( + new ModbusWriteCoilRequestBlueprint(SLAVE_SUBNET_ID, SLAVE_UNIT_ID, 300, bits, false, 1), + result -> { + unexpectedCount.incrementAndGet(); + callbackCalled.countDown(); + }, failure -> { + lastError.set(failure.getCause()); + callbackCalled.countDown(); + }); + assertTrue(callbackCalled.await(60, TimeUnit.SECONDS)); + + assertThat(unexpectedCount.get(), is(equalTo(0))); + assertInstanceOf(ModbusSlaveErrorResponseException.class, lastError.get(), lastError.toString()); + + assertThat(modbustRequestCaptor.getAllReturnValues().size(), is(equalTo(1))); + ModbusRequest request = modbustRequestCaptor.getAllReturnValues().get(0); + assertThat(request.getFunctionCode(), is(equalTo(5))); + assertThat(((WriteCoilRequest) request).getReference(), is(equalTo(300))); + assertThat(((WriteCoilRequest) request).getCoil(), is(equalTo(true))); + } + } + + /** + * Testing regular polling of coils + * + * Amount of requests is timed, and average poll period is checked + * + * @throws Exception + */ + @Test + public void testRegularReadEvery150msWithCoil() throws Exception { + generateData(); + ModbusSlaveEndpoint endpoint = getEndpoint(); + + AtomicInteger unexpectedCount = new AtomicInteger(); + CountDownLatch callbackCalled = new CountDownLatch(5); + AtomicInteger dataReceived = new AtomicInteger(); + + long start = System.currentTimeMillis(); + try (ModbusCommunicationInterface comms = modbusManager.newModbusCommunicationInterface(endpoint, null)) { + comms.registerRegularPoll(new ModbusReadRequestBlueprint(SLAVE_SUBNET_ID, SLAVE_UNIT_ID, + ModbusReadFunctionCode.READ_COILS, 1, 15, 1), 150, 0, result -> { + Optional bitsOptional = result.getBits(); + if (bitsOptional.isPresent()) { + BitArray bits = bitsOptional.get(); + dataReceived.incrementAndGet(); + try { + assertThat(bits.size(), is(equalTo(15))); + testCoilValues(bits, 1); + } catch (AssertionError e) { + unexpectedCount.incrementAndGet(); + } + } else { + unexpectedCount.incrementAndGet(); + } + callbackCalled.countDown(); + }, failure -> { + unexpectedCount.incrementAndGet(); + callbackCalled.countDown(); + }); + assertTrue(callbackCalled.await(60, TimeUnit.SECONDS)); + + long end = System.currentTimeMillis(); + assertPollDetails(unexpectedCount, dataReceived, start, end, 145, 500); + } + } + + /** + * Testing regular polling of holding registers + * + * Amount of requests is timed, and average poll period is checked + * + * @throws Exception + */ + @Test + public void testRegularReadEvery150msWithHolding() throws Exception { + generateData(); + ModbusSlaveEndpoint endpoint = getEndpoint(); + + AtomicInteger unexpectedCount = new AtomicInteger(); + CountDownLatch callbackCalled = new CountDownLatch(5); + AtomicInteger dataReceived = new AtomicInteger(); + + long start = System.currentTimeMillis(); + try (ModbusCommunicationInterface comms = modbusManager.newModbusCommunicationInterface(endpoint, null)) { + comms.registerRegularPoll(new ModbusReadRequestBlueprint(SLAVE_SUBNET_ID, SLAVE_UNIT_ID, + ModbusReadFunctionCode.READ_MULTIPLE_REGISTERS, 1, 15, 1), 150, 0, result -> { + Optional registersOptional = result.getRegisters(); + if (registersOptional.isPresent()) { + ModbusRegisterArray registers = registersOptional.get(); + dataReceived.incrementAndGet(); + try { + assertThat(registers.size(), is(equalTo(15))); + testHoldingValues(registers, 1); + } catch (AssertionError e) { + unexpectedCount.incrementAndGet(); + } + } else { + unexpectedCount.incrementAndGet(); + } + callbackCalled.countDown(); + }, failure -> { + unexpectedCount.incrementAndGet(); + callbackCalled.countDown(); + }); + assertTrue(callbackCalled.await(60, TimeUnit.SECONDS)); + long end = System.currentTimeMillis(); + assertPollDetails(unexpectedCount, dataReceived, start, end, 145, 500); + } + } + + @Test + public void testRegularReadFirstErrorThenOK() throws Exception { + generateData(); + ModbusSlaveEndpoint endpoint = getEndpoint(); + + AtomicInteger unexpectedCount = new AtomicInteger(); + CountDownLatch callbackCalled = new CountDownLatch(5); + AtomicInteger dataReceived = new AtomicInteger(); + + long start = System.currentTimeMillis(); + try (ModbusCommunicationInterface comms = modbusManager.newModbusCommunicationInterface(endpoint, null)) { + comms.registerRegularPoll(new ModbusReadRequestBlueprint(SLAVE_SUBNET_ID, SLAVE_UNIT_ID, + ModbusReadFunctionCode.READ_MULTIPLE_REGISTERS, 1, 15, 1), 150, 0, result -> { + Optional registersOptional = result.getRegisters(); + if (registersOptional.isPresent()) { + ModbusRegisterArray registers = registersOptional.get(); + dataReceived.incrementAndGet(); + try { + assertThat(registers.size(), is(equalTo(15))); + testHoldingValues(registers, 1); + } catch (AssertionError e) { + unexpectedCount.incrementAndGet(); + } + } else { + unexpectedCount.incrementAndGet(); + } + callbackCalled.countDown(); + }, failure -> { + unexpectedCount.incrementAndGet(); + callbackCalled.countDown(); + }); + assertTrue(callbackCalled.await(60, TimeUnit.SECONDS)); + long end = System.currentTimeMillis(); + assertPollDetails(unexpectedCount, dataReceived, start, end, 145, 500); + } + } + + /** + * + * @param unexpectedCount number of unexpected callback calls + * @param callbackCalled number of callback calls (including unexpected) + * @param dataReceived number of expected callback calls (onBits or onRegisters) + * @param pollStartMillis poll start time in milliepoch + * @param expectedPollAverageMin average poll period should be at least greater than this + * @param expectedPollAverageMax average poll period less than this + * @throws InterruptedException + */ + private void assertPollDetails(AtomicInteger unexpectedCount, AtomicInteger expectedCount, long pollStartMillis, + long pollEndMillis, int expectedPollAverageMin, int expectedPollAverageMax) throws InterruptedException { + int responses = expectedCount.get(); + assertThat(unexpectedCount.get(), is(equalTo(0))); + assertTrue(responses > 1); + + // Rest of the (timing-sensitive) assertions are not run in CI + assumeFalse(isRunningInCI(), "Running in CI! Will not test timing-sensitive details"); + float averagePollPeriodMillis = ((float) (pollEndMillis - pollStartMillis)) / (responses - 1); + assertTrue(averagePollPeriodMillis > expectedPollAverageMin && averagePollPeriodMillis < expectedPollAverageMax, + String.format( + "Measured avarage poll period %f ms (%d responses in %d ms) is not withing expected limits [%d, %d]", + averagePollPeriodMillis, responses, pollEndMillis - pollStartMillis, expectedPollAverageMin, + expectedPollAverageMax)); + } + + @Test + public void testUnregisterPollingOnClose() throws Exception { + ModbusSlaveEndpoint endpoint = getEndpoint(); + + AtomicInteger unexpectedCount = new AtomicInteger(); + AtomicInteger errorCount = new AtomicInteger(); + CountDownLatch successfulCountDownLatch = new CountDownLatch(3); + AtomicInteger expectedReceived = new AtomicInteger(); + + long start = System.currentTimeMillis(); + try (ModbusCommunicationInterface comms = modbusManager.newModbusCommunicationInterface(endpoint, null)) { + comms.registerRegularPoll(new ModbusReadRequestBlueprint(SLAVE_SUBNET_ID, SLAVE_UNIT_ID, + ModbusReadFunctionCode.READ_MULTIPLE_REGISTERS, 1, 15, 1), 200, 0, result -> { + Optional registersOptional = result.getRegisters(); + if (registersOptional.isPresent()) { + expectedReceived.incrementAndGet(); + successfulCountDownLatch.countDown(); + } else { + // bits + unexpectedCount.incrementAndGet(); + } + }, failure -> { + if (spi.getDigitalInCount() > 0) { + // No errors expected after server filled with data + unexpectedCount.incrementAndGet(); + } else { + expectedReceived.incrementAndGet(); + errorCount.incrementAndGet(); + generateData(); + successfulCountDownLatch.countDown(); + } + }); + // Wait for N successful responses before proceeding with assertions of poll rate + assertTrue(successfulCountDownLatch.await(60, TimeUnit.SECONDS)); + + long end = System.currentTimeMillis(); + assertPollDetails(unexpectedCount, expectedReceived, start, end, 190, 600); + + // wait some more and ensure nothing comes back + Thread.sleep(500); + assertThat(unexpectedCount.get(), is(equalTo(0))); + } + } + + @Test + public void testUnregisterPollingExplicit() throws Exception { + ModbusSlaveEndpoint endpoint = getEndpoint(); + + AtomicInteger unexpectedCount = new AtomicInteger(); + AtomicInteger errorCount = new AtomicInteger(); + CountDownLatch callbackCalled = new CountDownLatch(3); + AtomicInteger expectedReceived = new AtomicInteger(); + + long start = System.currentTimeMillis(); + try (ModbusCommunicationInterface comms = modbusManager.newModbusCommunicationInterface(endpoint, null)) { + PollTask task = comms.registerRegularPoll(new ModbusReadRequestBlueprint(SLAVE_SUBNET_ID, SLAVE_UNIT_ID, + ModbusReadFunctionCode.READ_MULTIPLE_REGISTERS, 1, 15, 1), 200, 0, result -> { + Optional registersOptional = result.getRegisters(); + if (registersOptional.isPresent()) { + expectedReceived.incrementAndGet(); + } else { + // bits + unexpectedCount.incrementAndGet(); + } + callbackCalled.countDown(); + }, failure -> { + if (spi.getDigitalInCount() > 0) { + // No errors expected after server filled with data + unexpectedCount.incrementAndGet(); + } else { + expectedReceived.incrementAndGet(); + errorCount.incrementAndGet(); + generateData(); + } + }); + assertTrue(callbackCalled.await(60, TimeUnit.SECONDS)); + long end = System.currentTimeMillis(); + assertPollDetails(unexpectedCount, expectedReceived, start, end, 190, 600); + + // Explicitly unregister the regular poll + comms.unregisterRegularPoll(task); + + // wait some more and ensure nothing comes back + Thread.sleep(500); + assertThat(unexpectedCount.get(), is(equalTo(0))); + } + } + + @SuppressWarnings("null") + @Test + public void testPoolConfigurationWithoutListener() throws Exception { + EndpointPoolConfiguration defaultConfig = modbusManager.getEndpointPoolConfiguration(getEndpoint()); + assertThat(defaultConfig, is(notNullValue())); + + EndpointPoolConfiguration newConfig = new EndpointPoolConfiguration(); + newConfig.setConnectMaxTries(5); + try (ModbusCommunicationInterface comms = modbusManager.newModbusCommunicationInterface(getEndpoint(), + newConfig)) { + // Sets configuration for the endpoint implicitly + } + + assertThat(modbusManager.getEndpointPoolConfiguration(getEndpoint()).getConnectMaxTries(), is(equalTo(5))); + assertThat(modbusManager.getEndpointPoolConfiguration(getEndpoint()), is(not(equalTo(defaultConfig)))); + + // Reset config + try (ModbusCommunicationInterface comms = modbusManager.newModbusCommunicationInterface(getEndpoint(), null)) { + // Sets configuration for the endpoint implicitly + } + // Should match the default + assertThat(modbusManager.getEndpointPoolConfiguration(getEndpoint()), is(equalTo(defaultConfig))); + } + + @Test + public void testConnectionCloseAfterLastCommunicationInterfaceClosed() throws Exception { + assumeFalse(isRunningInCI(), "Running in CI! Will not test timing-sensitive details"); + ModbusSlaveEndpoint endpoint = getEndpoint(); + assumeTrue(endpoint instanceof ModbusTCPSlaveEndpoint, + "Connection closing test supported only with TCP slaves"); + + // Generate server data + generateData(); + + EndpointPoolConfiguration config = new EndpointPoolConfiguration(); + config.setReconnectAfterMillis(9_000_000); + + // 1. capture open connections at this point + long openSocketsBefore = getNumberOfOpenClients(SOCKET_SPY); + assertThat(openSocketsBefore, is(equalTo(0L))); + + // 2. make poll, binding opens the tcp connection + try (ModbusCommunicationInterface comms = modbusManager.newModbusCommunicationInterface(endpoint, config)) { + { + CountDownLatch latch = new CountDownLatch(1); + comms.submitOneTimePoll( + new ModbusReadRequestBlueprint(1, 1, ModbusReadFunctionCode.READ_COILS, 0, 1, 1), response -> { + latch.countDown(); + }, failure -> { + latch.countDown(); + }); + assertTrue(latch.await(60, TimeUnit.SECONDS)); + } + waitForAssert(() -> { + // 3. ensure one open connection + long openSocketsAfter = getNumberOfOpenClients(SOCKET_SPY); + assertThat(openSocketsAfter, is(equalTo(1L))); + }); + try (ModbusCommunicationInterface comms2 = modbusManager.newModbusCommunicationInterface(endpoint, + config)) { + { + CountDownLatch latch = new CountDownLatch(1); + comms.submitOneTimePoll( + new ModbusReadRequestBlueprint(1, 1, ModbusReadFunctionCode.READ_COILS, 0, 1, 1), + response -> { + latch.countDown(); + }, failure -> { + latch.countDown(); + }); + assertTrue(latch.await(60, TimeUnit.SECONDS)); + } + assertThat(getNumberOfOpenClients(SOCKET_SPY), is(equalTo(1L))); + // wait for moment (to check that no connections are closed) + Thread.sleep(1000); + // no more than 1 connection, even though requests are going through + assertThat(getNumberOfOpenClients(SOCKET_SPY), is(equalTo(1L))); + } + Thread.sleep(1000); + // Still one connection open even after closing second connection + assertThat(getNumberOfOpenClients(SOCKET_SPY), is(equalTo(1L))); + } // 4. close (the last) comms + // ensure that open connections are closed + // (despite huge "reconnect after millis") + waitForAssert(() -> { + long openSocketsAfterClose = getNumberOfOpenClients(SOCKET_SPY); + assertThat(openSocketsAfterClose, is(equalTo(0L))); + }); + } + + @Test + public void testConnectionCloseAfterOneOffPoll() throws Exception { + assumeFalse(isRunningInCI(), "Running in CI! Will not test timing-sensitive details"); + ModbusSlaveEndpoint endpoint = getEndpoint(); + assumeTrue(endpoint instanceof ModbusTCPSlaveEndpoint, + "Connection closing test supported only with TCP slaves"); + + // Generate server data + generateData(); + + EndpointPoolConfiguration config = new EndpointPoolConfiguration(); + config.setReconnectAfterMillis(2_000); + + // 1. capture open connections at this point + long openSocketsBefore = getNumberOfOpenClients(SOCKET_SPY); + assertThat(openSocketsBefore, is(equalTo(0L))); + + // 2. make poll, binding opens the tcp connection + try (ModbusCommunicationInterface comms = modbusManager.newModbusCommunicationInterface(endpoint, config)) { + { + CountDownLatch latch = new CountDownLatch(1); + comms.submitOneTimePoll( + new ModbusReadRequestBlueprint(1, 1, ModbusReadFunctionCode.READ_COILS, 0, 1, 1), response -> { + latch.countDown(); + }, failure -> { + latch.countDown(); + }); + assertTrue(latch.await(60, TimeUnit.SECONDS)); + } + // Right after the poll we should have one connection open + waitForAssert(() -> { + // 3. ensure one open connection + long openSocketsAfter = getNumberOfOpenClients(SOCKET_SPY); + assertThat(openSocketsAfter, is(equalTo(1L))); + }); + // 4. Connection should close itself by the commons pool eviction policy (checking for old idle connection + // every now and then) + waitForAssert(() -> { + // 3. ensure one open connection + long openSocketsAfter = getNumberOfOpenClients(SOCKET_SPY); + assertThat(openSocketsAfter, is(equalTo(0L))); + }, 60_000, 50); + } + } + + private long getNumberOfOpenClients(SpyingSocketFactory socketSpy) { + localAddress(); + return socketSpy.sockets.stream().filter(this::isConnectedToTestServer).count(); + } + + /** + * Spy all sockets that are created + * + * @author Sami Salonen + * + */ + private static class SpyingSocketFactory implements SocketImplFactory { + + Queue sockets = new ConcurrentLinkedQueue(); + + @Override + public SocketImpl createSocketImpl() { + SocketImpl socket = newSocksSocketImpl(); + sockets.add(socket); + return socket; + } + } + + private static SocketImpl newSocksSocketImpl() { + try { + Class socksSocketImplClass = Class.forName("java.net.SocksSocketImpl"); + Class socketImplClass = SocketImpl.class; + + // // For Debugging + // for (Method method : socketImplClass.getDeclaredMethods()) { + // LoggerFactory.getLogger("foobar") + // .error("SocketImpl." + method.getName() + Arrays.toString(method.getParameters())); + // } + // for (Constructor constructor : socketImplClass.getDeclaredConstructors()) { + // LoggerFactory.getLogger("foobar") + // .error("SocketImpl." + constructor.getName() + Arrays.toString(constructor.getParameters())); + // } + // for (Method method : socksSocketImplClass.getDeclaredMethods()) { + // LoggerFactory.getLogger("foobar") + // .error("SocksSocketImpl." + method.getName() + Arrays.toString(method.getParameters())); + // } + // for (Constructor constructor : socksSocketImplClass.getDeclaredConstructors()) { + // LoggerFactory.getLogger("foobar").error( + // "SocksSocketImpl." + constructor.getName() + Arrays.toString(constructor.getParameters())); + // } + + try { + Constructor constructor = socksSocketImplClass.getDeclaredConstructor(); + constructor.setAccessible(true); + return (SocketImpl) Objects.requireNonNull(constructor.newInstance()); + } catch (NoSuchMethodException e) { + // Newer Javas (Java 14->) do not have default constructor 'SocksSocketImpl()' + // Instead we use "static SocketImpl.createPlatformSocketImpl" and "SocksSocketImpl(SocketImpl) + Method socketImplCreateMethod = socketImplClass.getDeclaredMethod("createPlatformSocketImpl", + boolean.class); + socketImplCreateMethod.setAccessible(true); + Object socketImpl = socketImplCreateMethod.invoke(/* null since we deal with static method */ null, + /* server */false); + + Constructor socksSocketImplConstructor = socksSocketImplClass + .getDeclaredConstructor(socketImplClass); + socksSocketImplConstructor.setAccessible(true); + return (SocketImpl) Objects.requireNonNull(socksSocketImplConstructor.newInstance(socketImpl)); + } + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private boolean isConnectedToTestServer(SocketImpl impl) { + final InetAddress testServerAddress = localAddress(); + + final int port; + boolean connected = true; + final InetAddress address; + try { + Method getPort = SocketImpl.class.getDeclaredMethod("getPort"); + getPort.setAccessible(true); + port = (int) getPort.invoke(impl); + + Method getInetAddressMethod = SocketImpl.class.getDeclaredMethod("getInetAddress"); + getInetAddressMethod.setAccessible(true); + address = (InetAddress) getInetAddressMethod.invoke(impl); + + // hacky (but java8-14 compatible) way to know if socket is open + // SocketImpl.getOption throws IOException when socket is closed + Method getOption = SocketImpl.class.getDeclaredMethod("getOption", SocketOption.class); + getOption.setAccessible(true); + try { + getOption.invoke(impl, StandardSocketOptions.SO_KEEPALIVE); + } catch (InvocationTargetException e) { + if (e.getTargetException() instanceof IOException) { + connected = false; + } else { + throw e; + } + } + } catch (Exception e) { + throw new IllegalStateException(e); + } + return port == tcpModbusPort && connected && address.equals(testServerAddress); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/ValueBufferTest.java b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/ValueBufferTest.java new file mode 100644 index 00000000000..932aa068247 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/ValueBufferTest.java @@ -0,0 +1,188 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.test; + +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.BufferOverflowException; +import java.nio.InvalidMarkException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.core.io.transport.sbus.ValueBuffer; + +/** + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public class ValueBufferTest { + + @Test + public void testInt32Int8() { + ValueBuffer wrap = ValueBuffer.wrap(new byte[] { (byte) 0xFF, (byte) 0xFF, (byte) 0xFC, 0x14, 3, -1, -2 }); + assertEquals(7, wrap.remaining()); + assertTrue(wrap.hasRemaining()); + + assertEquals(-1004, wrap.getSInt32()); + assertEquals(3, wrap.remaining()); + assertTrue(wrap.hasRemaining()); + + assertEquals(3, wrap.getSInt8()); + assertEquals(2, wrap.remaining()); + assertTrue(wrap.hasRemaining()); + + assertEquals(-1, wrap.getSInt8()); + assertEquals(1, wrap.remaining()); + assertTrue(wrap.hasRemaining()); + + assertEquals(254, wrap.getUInt8()); + assertEquals(0, wrap.remaining()); + assertFalse(wrap.hasRemaining()); + } + + @Test + public void testOutOfBounds() { + ValueBuffer wrap = ValueBuffer.wrap(new byte[] { (byte) 0xFF, (byte) 0xFF, (byte) 0xFC, 0x14, 3, -1, -2 }); + wrap.position(7); + assertThrows(IllegalArgumentException.class, () -> wrap.getSInt8()); + } + + @Test + public void testOutOfBound2s() { + ValueBuffer wrap = ValueBuffer.wrap(new byte[] { (byte) 0xFF, (byte) 0xFF, (byte) 0xFC, 0x14, 3, -1, -2 }); + wrap.position(6); + assertThrows(IllegalArgumentException.class, () -> wrap.getSInt16()); + } + + @Test + public void testMarkReset() { + ValueBuffer wrap = ValueBuffer.wrap(new byte[] { (byte) 0xFF, (byte) 0xFF, (byte) 0xFC, 0x14, 3, -1, -2 }); + wrap.mark(); + assertEquals(-1004, wrap.getSInt32()); + wrap.reset(); + assertEquals(4294966292L, wrap.getUInt32()); + wrap.mark(); + assertEquals(3, wrap.getSInt8()); + wrap.reset(); + assertEquals(3, wrap.getSInt8()); + assertEquals(-1, wrap.getSInt8()); + assertEquals(254, wrap.getUInt8()); + } + + @Test + public void testMarkHigherThanPosition() { + ValueBuffer wrap = ValueBuffer.wrap(new byte[] { (byte) 0xFF, (byte) 0xFF, (byte) 0xFC, 0x14, 3, -1, -2 }); + assertEquals(-1004, wrap.getSInt32()); + wrap.position(4); + wrap.mark(); + assertEquals(4, wrap.position()); + + // mark = position + wrap.position(4); + assertEquals(4, wrap.position()); + wrap.reset(); + assertEquals(4, wrap.position()); + + // position < mark + wrap.position(3); // Mark is removed here + assertEquals(3, wrap.position()); + boolean caughtException = false; + try { + wrap.reset(); + } catch (InvalidMarkException e) { + // OK, expected + caughtException = true; + } + assertTrue(caughtException); + assertEquals(3, wrap.position()); + + // Mark is removed. Reset unaccessible even with original position of 4 + wrap.position(4); + assertEquals(4, wrap.position()); + caughtException = false; + try { + wrap.reset(); + } catch (InvalidMarkException e) { + // OK, expected + caughtException = true; + } + assertTrue(caughtException); + } + + @Test + public void testMarkLowerThanPosition() { + ValueBuffer wrap = ValueBuffer.wrap(new byte[] { (byte) 0xFF, (byte) 0xFF, (byte) 0xFC, 0x14, 3, -1, -2 }); + assertEquals(-1004, wrap.getSInt32()); + wrap.position(4); + wrap.mark(); + assertEquals(4, wrap.position()); + + // mark = position + wrap.position(4); + assertEquals(4, wrap.position()); + wrap.reset(); + assertEquals(4, wrap.position()); + + // mark > position + wrap.position(6); + assertEquals(6, wrap.position()); + wrap.reset(); + assertEquals(4, wrap.position()); + } + + @Test + public void testPosition() { + ValueBuffer wrap = ValueBuffer.wrap(new byte[] { 0, 0, 0, 1, 3, -1, -2 }); + assertEquals(0, wrap.position()); + + wrap.position(4); + assertEquals(4, wrap.position()); + assertEquals(3, wrap.getSInt8()); + assertEquals(5, wrap.position()); + } + + @Test + public void testBulkGetBufferOverflow() { + ValueBuffer wrap = ValueBuffer.wrap(new byte[] { 0, 0 }); + byte[] threeBytes = new byte[3]; + assertThrows(BufferOverflowException.class, () -> wrap.get(threeBytes)); + } + + @Test + public void testBulkGetAtCapacity() { + ValueBuffer wrap = ValueBuffer.wrap(new byte[] { 1, 2 }); + byte[] twoBytes = new byte[2]; + wrap.get(twoBytes); + assertEquals(1, twoBytes[0]); + assertEquals(2, twoBytes[1]); + assertEquals(2, wrap.position()); + assertFalse(wrap.hasRemaining()); + } + + @Test + public void testBulkGet() { + ValueBuffer wrap = ValueBuffer.wrap(new byte[] { 1, 2, 3 }); + byte[] onebyte = new byte[1]; + wrap.get(onebyte); + assertEquals(1, onebyte[0]); + assertEquals(1, wrap.position()); + + // non-zero position + byte[] twoBytes = new byte[2]; + wrap.position(1); + wrap.get(twoBytes); + assertEquals(2, twoBytes[0]); + assertEquals(3, twoBytes[1]); + assertEquals(3, wrap.position()); + } +} diff --git a/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/WriteRequestJsonUtilitiesTest.java b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/WriteRequestJsonUtilitiesTest.java new file mode 100644 index 00000000000..1304d7f5c21 --- /dev/null +++ b/bundles/org.openhab.core.io.transport.sbus/src/test/java/org/openhab/core/io/transport/sbus/test/WriteRequestJsonUtilitiesTest.java @@ -0,0 +1,227 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.transport.sbus.test; + +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.collection.ArrayMatching.arrayContaining; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.openhab.core.io.transport.sbus.ModbusConstants.*; + +import java.util.Collection; +import java.util.List; +import java.util.stream.IntStream; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.hamcrest.Matcher; +import org.junit.jupiter.api.Test; +import org.openhab.core.io.transport.sbus.ModbusWriteFunctionCode; +import org.openhab.core.io.transport.sbus.ModbusWriteRequestBlueprint; +import org.openhab.core.io.transport.sbus.json.WriteRequestJsonUtilities; + +/** + * @author Sami Salonen - Initial contribution + */ +@NonNullByDefault +public class WriteRequestJsonUtilitiesTest { + + private static final List MAX_REGISTERS = IntStream.range(0, MAX_REGISTERS_WRITE_COUNT).mapToObj(i -> "1") + .toList(); + private static final List OVER_MAX_REGISTERS = IntStream.range(0, MAX_REGISTERS_WRITE_COUNT + 1) + .mapToObj(i -> "1").toList(); + + private static final List MAX_COILS = IntStream.range(0, MAX_BITS_WRITE_COUNT).mapToObj(i -> "1").toList(); + private static final List OVER_MAX_COILS = IntStream.range(0, MAX_BITS_WRITE_COUNT + 1).mapToObj(i -> "1") + .toList(); + + @Test + public void testEmptyArray() { + assertThat(WriteRequestJsonUtilities.fromJson(1, 3, "[]").size(), is(equalTo(0))); + } + + @Test + public void testFC6NoRegister() { + assertThrows(IllegalArgumentException.class, () -> WriteRequestJsonUtilities.fromJson(1, 55, "[{"// + + "\"functionCode\": 6,"// + + "\"address\": 5412,"// + + "\"value\": []"// + + "}]")); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + @Test + public void testFC6SingleRegister() { + assertThat(WriteRequestJsonUtilities.fromJson(1, 55, "[{"// + + "\"functionCode\": 6,"// + + "\"address\": 5412,"// + + "\"value\": [3]"// + + "}]").toArray(), + arrayContaining((Matcher) new RegisterMatcher(55, 5412, WriteRequestJsonUtilities.DEFAULT_MAX_TRIES, + ModbusWriteFunctionCode.WRITE_SINGLE_REGISTER, 3))); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + @Test + public void testFC6SingleRegisterMaxTries99() { + assertThat(WriteRequestJsonUtilities.fromJson(1, 55, "[{"// + + "\"functionCode\": 6,"// + + "\"address\": 5412,"// + + "\"value\": [3],"// + + "\"maxTries\": 99"// + + "}]").toArray(), + arrayContaining( + (Matcher) new RegisterMatcher(55, 5412, 99, ModbusWriteFunctionCode.WRITE_SINGLE_REGISTER, 3))); + } + + @Test + public void testFC6MultipleRegisters() { + assertThrows(IllegalArgumentException.class, () -> WriteRequestJsonUtilities.fromJson(1, 55, "[{"// + + "\"functionCode\": 6,"// + + "\"address\": 5412,"// + + "\"value\": [3, 4]"// + + "}]")); + } + + @Test + public void testFC16NoRegister() { + assertThrows(IllegalArgumentException.class, () -> WriteRequestJsonUtilities.fromJson(1, 55, "[{"// + + "\"functionCode\": 16,"// + + "\"address\": 5412,"// + + "\"value\": []"// + + "}]")); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + @Test + public void testFC16SingleRegister() { + assertThat(WriteRequestJsonUtilities.fromJson(1, 55, "[{"// + + "\"functionCode\": 16,"// + + "\"address\": 5412,"// + + "\"value\": [3]"// + + "}]").toArray(), + arrayContaining((Matcher) new RegisterMatcher(55, 5412, WriteRequestJsonUtilities.DEFAULT_MAX_TRIES, + ModbusWriteFunctionCode.WRITE_MULTIPLE_REGISTERS, 3))); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + @Test + public void testFC16MultipleRegisters() { + assertThat(WriteRequestJsonUtilities.fromJson(1, 55, "[{"// + + "\"functionCode\": 16,"// + + "\"address\": 5412,"// + + "\"value\": [3, 4, 2]"// + + "}]").toArray(), + arrayContaining((Matcher) new RegisterMatcher(55, 5412, WriteRequestJsonUtilities.DEFAULT_MAX_TRIES, + ModbusWriteFunctionCode.WRITE_MULTIPLE_REGISTERS, 3, 4, 2))); + } + + @Test + public void testFC16MultipleRegistersMaxRegisters() { + Collection writes = WriteRequestJsonUtilities.fromJson(1, 55, "[{"// + + "\"functionCode\": 16,"// + + "\"address\": 5412,"// + + "\"value\": [" + String.join(",", MAX_REGISTERS) + "]"// + + "}]"); + assertThat(writes.size(), is(equalTo(1))); + } + + @Test + public void testFC16MultipleRegistersTooManyRegisters() { + assertThrows(IllegalArgumentException.class, () -> WriteRequestJsonUtilities.fromJson(1, 55, "[{"// + + "\"functionCode\": 16,"// + + "\"address\": 5412,"// + + "\"value\": [" + String.join(",", OVER_MAX_REGISTERS) + "]"// + + "}]")); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + @Test + public void testFC5SingeCoil() { + assertThat(WriteRequestJsonUtilities.fromJson(1, 55, "[{"// + + "\"functionCode\": 5,"// + + "\"address\": 5412,"// + + "\"value\": [3]" // value 3 (!= 0) is converted to boolean true + + "}]").toArray(), + arrayContaining((Matcher) new CoilMatcher(55, 5412, WriteRequestJsonUtilities.DEFAULT_MAX_TRIES, + ModbusWriteFunctionCode.WRITE_COIL, true))); + } + + @Test + public void testFC5MultipleCoils() { + assertThrows(IllegalArgumentException.class, () -> WriteRequestJsonUtilities.fromJson(1, 55, "[{"// + + "\"functionCode\": 5,"// + + "\"address\": 5412,"// + + "\"value\": [3, 4]"// + + "}]")); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + @Test + public void testFC15SingleCoil() { + assertThat(WriteRequestJsonUtilities.fromJson(1, 55, "[{"// + + "\"functionCode\": 15,"// + + "\"address\": 5412,"// + + "\"value\": [3]"// + + "}]").toArray(), + arrayContaining((Matcher) new CoilMatcher(55, 5412, WriteRequestJsonUtilities.DEFAULT_MAX_TRIES, + ModbusWriteFunctionCode.WRITE_MULTIPLE_COILS, true))); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + @Test + public void testFC15MultipleCoils() { + assertThat(WriteRequestJsonUtilities.fromJson(1, 55, "[{"// + + "\"functionCode\": 15,"// + + "\"address\": 5412,"// + + "\"value\": [1, 0, 5]"// + + "}]").toArray(), + arrayContaining((Matcher) new CoilMatcher(55, 5412, WriteRequestJsonUtilities.DEFAULT_MAX_TRIES, + ModbusWriteFunctionCode.WRITE_MULTIPLE_COILS, true, false, true))); + } + + @Test + public void testFC15MultipleCoilsMaxCoils() { + Collection writes = WriteRequestJsonUtilities.fromJson(1, 55, "[{"// + + "\"functionCode\": 15,"// + + "\"address\": 5412,"// + + "\"value\": [" + String.join(",", MAX_COILS) + "]"// + + "}]"); + assertThat(writes.size(), is(equalTo(1))); + } + + @Test + public void testFC15MultipleCoilsTooManyCoils() { + assertThrows(IllegalArgumentException.class, () -> WriteRequestJsonUtilities.fromJson(1, 55, "[{"// + + "\"functionCode\": 15,"// + + "\"address\": 5412,"// + + "\"value\": [" + String.join(",", OVER_MAX_COILS) + "]"// + + "}]")); + } + + @Test + public void testEmptyObject() { + // we are expecting list, not object -> error + assertThrows(IllegalStateException.class, () -> WriteRequestJsonUtilities.fromJson(1, 3, "{}")); + } + + @Test + public void testNumber() { + // we are expecting list, not primitive (number) -> error + assertThrows(IllegalStateException.class, () -> WriteRequestJsonUtilities.fromJson(1, 3, "3")); + } + + @Test + public void testEmptyList() { + assertThat(WriteRequestJsonUtilities.fromJson(1, 3, "[]").size(), is(equalTo(0))); + } +} diff --git a/features/karaf/openhab-core/src/main/feature/feature.xml b/features/karaf/openhab-core/src/main/feature/feature.xml index a5062604397..6495ac11885 100644 --- a/features/karaf/openhab-core/src/main/feature/feature.xml +++ b/features/karaf/openhab-core/src/main/feature/feature.xml @@ -254,6 +254,14 @@ mvn:org.openhab.core.bundles/org.openhab.core.io.transport.modbus/${project.version} + + openhab-core-base + openhab-transport-serial + + mvn:org.apache.commons/commons-pool2/2.8.1 + mvn:org.openhab.core.bundles/org.openhab.core.io.transport.sbus/${project.version} + + openhab-core-base @@ -499,6 +507,10 @@ openhab-core-io-transport-modbus + + openhab-core-io-transport-sbus + + openhab-core-io-transport-mqtt