diff --git a/build.gradle.kts b/build.gradle.kts index e6bb40c959..49e63a3584 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -84,6 +84,7 @@ dependencies { edgeModule("com.hivemq:hivemq-edge-module-plc4x") edgeModule("com.hivemq:hivemq-edge-module-opcua") edgeModule("com.hivemq:hivemq-edge-module-modbus") + edgeModule("com.hivemq:hivemq-edge-module-s7") } val hivemqEdgeZip by tasks.registering(Zip::class) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 922f0401fe..ae5ad6ddd8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ hivemq-edge-adapterSdk = "2024.5" hivemq-edge-extensionSdk = "2024.5" hivemq-extensionSdk = "4.30.0" hivemq-mqttClient = "1.3.4" +iot-communication = "1.5.3" kotlin = "1.9.25" jackson = "2.18.2" jacoco = "0.8.11" @@ -79,6 +80,7 @@ hivemq-edge-adapterSdk = { module = "com.hivemq:hivemq-edge-adapter-sdk", versio hivemq-edge-extensionSdk = { module = "com.hivemq:hivemq-edge-extension-sdk", version.ref = "hivemq-edge-extensionSdk" } hivemq-extensionSdk = { module = "com.hivemq:hivemq-extension-sdk", version.ref = "hivemq-extensionSdk" } hivemq-mqttClient = { module = "com.hivemq:hivemq-mqtt-client", version.ref = "hivemq-mqttClient" } +iot-communication = { module = "com.github.xingshuangs:iot-communication", version.ref = "iot-communication" } kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin" } jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" } jackson-databind-nullable = { module = "org.openapitools:jackson-databind-nullable", version.ref = "jacksonDatabindNullable" } diff --git a/hivemq-edge/build.gradle.kts b/hivemq-edge/build.gradle.kts index 94c6a4fd4e..b27d353ddb 100644 --- a/hivemq-edge/build.gradle.kts +++ b/hivemq-edge/build.gradle.kts @@ -210,6 +210,7 @@ dependencies { implementation(libs.victools.jsonschema.generator) implementation(libs.victools.jsonschema.jackson) implementation(libs.jsonSchemaInferrer) + } /* ******************** test ******************** */ diff --git a/hivemq-edge/src/main/resources/hivemq-edge-configuration.json b/hivemq-edge/src/main/resources/hivemq-edge-configuration.json index effca3dabf..190f0ddf4c 100644 --- a/hivemq-edge/src/main/resources/hivemq-edge-configuration.json +++ b/hivemq-edge/src/main/resources/hivemq-edge-configuration.json @@ -112,6 +112,25 @@ "external" : true } }, + { + "id" : "s7-new", + "version" : "2024.9", + "name" : "S7 to MQTT Protocol Adapter", + "description" : "Connects HiveMQ Edge to S7-300, S7-400, S7-1200 & S7-1500 devices, reading data from the PLC into MQTT.", + "author" : "HiveMQ", + "documentationLink" : { + "url" : "https://docs.hivemq.com/hivemq-edge/protocol-adapters.html#s7-adapter", + "external" : true + }, + "provisioningLink" : { + "url" : "https://github.com/hivemq/hivemq-edge/releases", + "external" : true + }, + "logoUrl" : { + "url" : "https://raw.githubusercontent.com/hivemq/hivemq-edge/master/modules/hivemq-edge-module-plc4x/src/main/resources/httpd/images/s7-icon.png", + "external" : true + } + }, { "id" : "ads", "version" : "2025.2", diff --git a/modules/hivemq-edge-module-s7/HEADER b/modules/hivemq-edge-module-s7/HEADER new file mode 100644 index 0000000000..068849ca5a --- /dev/null +++ b/modules/hivemq-edge-module-s7/HEADER @@ -0,0 +1,13 @@ +Copyright 2023-present HiveMQ GmbH + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/modules/hivemq-edge-module-s7/README.md b/modules/hivemq-edge-module-s7/README.md new file mode 100644 index 0000000000..f8cd8a3cca --- /dev/null +++ b/modules/hivemq-edge-module-s7/README.md @@ -0,0 +1,67 @@ +New: + +BOOL(Boolean.class), + +BYTE(Byte.class), + +INT16(Short.class), +UINT16(Short.class), + +INT32(Integer.class), +UINT32(Integer.class), + +INT64(Long.class), + +REAL(Float.class), + +LREAL(Double.class), + +TIME(Long.class) + +STRING(String.class), + +DATE(LocalDate.class), +TIME_OF_DAY(LocalTime.class), +DATE_AND_TIME(LocalDateTime.class), + + +NULL((short) 0x00, null), + +BOOL((short) 0x01, Boolean.class), + +BYTE((short) 0x02, Byte.class), +SINT((short) 0x21, Byte.class), + +WORD((short) 0x03, Short.class), +USINT((short) 0x11, Short.class), +INT((short) 0x22, Short.class), +WCHAR((short) 0x42, Short.class), + +DWORD((short) 0x04, Integer.class), +UINT((short) 0x12, Integer.class), +DINT((short) 0x23, Integer.class), + +LWORD((short) 0x05, Long.class), +UDINT((short) 0x13, Long.class), +LINT((short) 0x24, Long.class), + +ULINT((short) 0x14, BigInteger.class), + +STRING((short) 0x43, String.class), +WSTRING((short) 0x44, String.class), + +REAL((short) 0x31, Float.class), +LREAL((short) 0x32, Double.class), + +CHAR((short) 0x41, Character.class), + + +TIME((short) 0x51, Duration.class), +LTIME((short) 0x52, Duration.class), +DATE((short) 0x53, LocalDate.class), +LDATE((short) 0x54, LocalDate.class), +TIME_OF_DAY((short) 0x55, LocalTime.class), +LTIME_OF_DAY((short) 0x56, LocalTime.class), +DATE_AND_TIME((short) 0x57, LocalDateTime.class), +LDATE_AND_TIME((short) 0x58, LocalDateTime.class), +RAW_BYTE_ARRAY((short) 0x71, Byte.class); diff --git a/modules/hivemq-edge-module-s7/build.gradle.kts b/modules/hivemq-edge-module-s7/build.gradle.kts new file mode 100644 index 0000000000..df5e7ba8c9 --- /dev/null +++ b/modules/hivemq-edge-module-s7/build.gradle.kts @@ -0,0 +1,213 @@ +import nl.javadude.gradle.plugins.license.DownloadLicensesExtension.license +import org.gradle.api.tasks.testing.logging.TestExceptionFormat +import org.gradle.api.tasks.testing.logging.TestLogEvent.* + +plugins { + java + alias(libs.plugins.defaults) + alias(libs.plugins.shadow) + alias(libs.plugins.license) + id("com.hivemq.edge-version-updater") + id("com.hivemq.third-party-license-generator") +} + +group = "com.hivemq" + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } +} + +repositories { + mavenCentral() + maven { url = uri("https://jitpack.io") } + exclusiveContent { + forRepository { + maven { + url = uri("https://jitpack.io") + } + } + filter { + includeGroup("com.github.simon622.mqtt-sn") + includeGroup("com.github.simon622") + } + } +} + +dependencies { + compileOnly(libs.hivemq.edge.adapterSdk) + compileOnly(libs.apache.commonsIO) + compileOnly(libs.slf4j.api) + compileOnly(libs.jackson.databind) + implementation(libs.iot.communication) + +} + +dependencies { + testImplementation("com.hivemq:hivemq-edge") + testImplementation(libs.jackson.databind) + testImplementation(libs.hivemq.edge.adapterSdk) + testImplementation(libs.mockito.junitJupiter) + testImplementation(libs.junit.jupiter) + + testImplementation(libs.assertj) + testImplementation(libs.awaitility) +} + +tasks.test { + useJUnitPlatform() + testLogging { + events = setOf(STARTED, PASSED, FAILED, SKIPPED, STANDARD_ERROR) + exceptionFormat = TestExceptionFormat.FULL + } +} + +tasks.register("copyAllDependencies") { + shouldRunAfter("assemble") + from(configurations.runtimeClasspath) + into("${buildDir}/deps/libs") +} + +tasks.named("assemble") { finalizedBy("copyAllDependencies") } + +/* ******************** artifacts ******************** */ + +val releaseBinary: Configuration by configurations.creating { + isCanBeConsumed = true + isCanBeResolved = false + attributes { + attribute(Category.CATEGORY_ATTRIBUTE, objects.named("binary")) + attribute(Usage.USAGE_ATTRIBUTE, objects.named("release")) + } +} + +val thirdPartyLicenses: Configuration by configurations.creating { + isCanBeConsumed = true + isCanBeResolved = false + attributes { + attribute(Category.CATEGORY_ATTRIBUTE, objects.named("third-party-licenses")) + } +} + +artifacts { + add(releaseBinary.name, tasks.shadowJar) + add(thirdPartyLicenses.name, tasks.updateThirdPartyLicenses.flatMap { it.outputDirectory }) +} + +/* ******************** compliance ******************** */ + +license { + header = file("HEADER") + mapping("java", "SLASHSTAR_STYLE") +} + +downloadLicenses { + aliases = mapOf( + license("Apache License, Version 2.0", "https://opensource.org/licenses/Apache-2.0") to listOf( + "Apache 2", + "Apache 2.0", + "Apache-2.0", + "Apache License 2.0", + "Apache License, 2.0", + "Apache License v2.0", + "Apache License, Version 2", + "Apache License Version 2.0", + "Apache License, Version 2.0", + "Apache License, version 2.0", + "The Apache License, Version 2.0", + "Apache Software License - Version 2.0", + "Apache Software License, version 2.0", + "The Apache Software License, Version 2.0" + ), + license("MIT License", "https://opensource.org/licenses/MIT") to listOf( + "MIT License", + "MIT license", + "The MIT License", + "The MIT License (MIT)" + ), + license("CDDL, Version 1.0", "https://opensource.org/licenses/CDDL-1.0") to listOf( + "CDDL, Version 1.0", + "Common Development and Distribution License 1.0", + "COMMON DEVELOPMENT AND DISTRIBUTION LICENSE (CDDL) Version 1.0", + license("CDDL", "https://glassfish.dev.java.net/public/CDDLv1.0.html") + ), + license("CDDL, Version 1.1", "https://oss.oracle.com/licenses/CDDL+GPL-1.1") to listOf( + "CDDL 1.1", + "CDDL, Version 1.1", + "Common Development And Distribution License 1.1", + "CDDL+GPL License", + "CDDL + GPLv2 with classpath exception", + "Dual license consisting of the CDDL v1.1 and GPL v2", + "CDDL or GPLv2 with exceptions", + "CDDL/GPLv2+CE" + ), + license("LGPL, Version 2.0", "https://opensource.org/licenses/LGPL-2.0") to listOf( + "LGPL, Version 2.0", + "GNU General Public License, version 2" + ), + license("LGPL, Version 2.1", "https://opensource.org/licenses/LGPL-2.1") to listOf( + "LGPL, Version 2.1", + "LGPL, version 2.1", + "GNU Lesser General Public License version 2.1 (LGPLv2.1)", + license("GNU Lesser General Public License", "http://www.gnu.org/licenses/old-licenses/lgpl-2.1.html") + ), + license("LGPL, Version 3.0", "https://opensource.org/licenses/LGPL-3.0") to listOf( + "LGPL, Version 3.0", + "Lesser General Public License, version 3 or greater" + ), + license("EPL, Version 1.0", "https://opensource.org/licenses/EPL-1.0") to listOf( + "EPL, Version 1.0", + "Eclipse Public License - v 1.0", + "Eclipse Public License - Version 1.0", + license("Eclipse Public License", "http://www.eclipse.org/legal/epl-v10.html") + ), + license("EPL, Version 2.0", "https://opensource.org/licenses/EPL-2.0") to listOf( + "EPL 2.0", + "EPL, Version 2.0" + ), + license("EDL, Version 1.0", "https://www.eclipse.org/org/documents/edl-v10.php") to listOf( + "EDL 1.0", + "EDL, Version 1.0", + "Eclipse Distribution License - v 1.0" + ), + license("BSD 3-Clause License", "https://opensource.org/licenses/BSD-3-Clause") to listOf( + "BSD 3-clause", + "BSD-3-Clause", + "BSD 3-Clause License", + "3-Clause BSD License", + "New BSD License", + license("BSD", "http://asm.ow2.org/license.html"), + license("BSD", "http://asm.objectweb.org/license.html"), + license("BSD", "LICENSE.txt") + ), + license("Bouncy Castle License", "https://www.bouncycastle.org/licence.html") to listOf( + "Bouncy Castle Licence" + ), + license("W3C License", "https://opensource.org/licenses/W3C") to listOf( + "W3C License", + "W3C Software Copyright Notice and License", + "The W3C Software License" + ), + license("CC0", "https://creativecommons.org/publicdomain/zero/1.0/") to listOf( + "CC0", + "Public Domain" + ) + ) + + dependencyConfiguration = "runtimeClasspath" +} + +tasks.updateThirdPartyLicenses { + dependsOn(tasks.downloadLicenses) + projectName.set(project.name) + group = "license" + dependencyLicense.set(tasks.downloadLicenses.get().xmlDestination.resolve("dependency-license.xml")) + outputDirectory.set(layout.buildDirectory.dir("distribution/third-party-licenses")) +} + +val javaComponent = components["java"] as AdhocComponentWithVariants +javaComponent.withVariantsFromConfiguration(configurations.shadowRuntimeElements.get()) { + skip() +} + diff --git a/modules/hivemq-edge-module-s7/gradle.properties b/modules/hivemq-edge-module-s7/gradle.properties new file mode 100644 index 0000000000..477a931db8 --- /dev/null +++ b/modules/hivemq-edge-module-s7/gradle.properties @@ -0,0 +1,5 @@ +version=2024.8-SNAPSHOT + +# Enable build cache. +org.gradle.caching=true +org.gradle.jvmargs=-Xmx1024M diff --git a/modules/hivemq-edge-module-s7/gradle/dependency-check/suppress.xml b/modules/hivemq-edge-module-s7/gradle/dependency-check/suppress.xml new file mode 100644 index 0000000000..85bf31aae8 --- /dev/null +++ b/modules/hivemq-edge-module-s7/gradle/dependency-check/suppress.xml @@ -0,0 +1,18 @@ + + + + + ^org\.jsoup:jsoup:.*$ + CVE-2015-6748 + + + + ^javax\.ws\.rs:javax\.ws\.rs-api:.*$ + CVE-2015-4345 + + \ No newline at end of file diff --git a/modules/hivemq-edge-module-s7/gradle/tools/javadoc-cleaner-1.0.jar b/modules/hivemq-edge-module-s7/gradle/tools/javadoc-cleaner-1.0.jar new file mode 100644 index 0000000000..66659877e4 Binary files /dev/null and b/modules/hivemq-edge-module-s7/gradle/tools/javadoc-cleaner-1.0.jar differ diff --git a/modules/hivemq-edge-module-s7/gradle/tools/license-third-party-tool-2.0.jar b/modules/hivemq-edge-module-s7/gradle/tools/license-third-party-tool-2.0.jar new file mode 100644 index 0000000000..0135ec5b40 Binary files /dev/null and b/modules/hivemq-edge-module-s7/gradle/tools/license-third-party-tool-2.0.jar differ diff --git a/modules/hivemq-edge-module-s7/gradle/wrapper/gradle-wrapper.jar b/modules/hivemq-edge-module-s7/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000..a4b76b9530 Binary files /dev/null and b/modules/hivemq-edge-module-s7/gradle/wrapper/gradle-wrapper.jar differ diff --git a/modules/hivemq-edge-module-s7/gradle/wrapper/gradle-wrapper.properties b/modules/hivemq-edge-module-s7/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..09523c0e54 --- /dev/null +++ b/modules/hivemq-edge-module-s7/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/modules/hivemq-edge-module-s7/gradlew b/modules/hivemq-edge-module-s7/gradlew new file mode 100755 index 0000000000..f5feea6d6b --- /dev/null +++ b/modules/hivemq-edge-module-s7/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/modules/hivemq-edge-module-s7/gradlew.bat b/modules/hivemq-edge-module-s7/gradlew.bat new file mode 100644 index 0000000000..9d21a21834 --- /dev/null +++ b/modules/hivemq-edge-module-s7/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/modules/hivemq-edge-module-s7/settings.gradle.kts b/modules/hivemq-edge-module-s7/settings.gradle.kts new file mode 100644 index 0000000000..533f2c5007 --- /dev/null +++ b/modules/hivemq-edge-module-s7/settings.gradle.kts @@ -0,0 +1,13 @@ +rootProject.name = "hivemq-edge-module-s7" + +pluginManagement { + includeBuild("../../edge-plugins") +} + +dependencyResolutionManagement { + versionCatalogs { + create("libs") { + from(files("../../gradle/libs.versions.toml")) + } + } +} diff --git a/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/S7Client.java b/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/S7Client.java new file mode 100644 index 0000000000..d0fe694411 --- /dev/null +++ b/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/S7Client.java @@ -0,0 +1,122 @@ +package com.hivemq.edge.adapters.s7; + +import com.github.xingshuangs.iot.protocol.s7.enums.EPlcType; +import com.github.xingshuangs.iot.protocol.s7.service.S7PLC; +import com.hivemq.adapter.sdk.api.data.DataPoint; +import com.hivemq.adapter.sdk.api.factories.DataPointFactory; +import com.hivemq.edge.adapters.s7.config.S7AdapterConfig; +import com.hivemq.edge.adapters.s7.config.S7DataType; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.math.BigInteger; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +public class S7Client { + + private static final Logger log = LoggerFactory.getLogger(S7Client.class); + + private final S7PLC s7PLC; + + private final DataPointFactory dataPointFactory; + + public S7Client(final @NotNull EPlcType elpcType, final @NotNull String hostname, final int port, final int rack, final int slot, final int pduLength, final @NotNull DataPointFactory dataPointFactory) { + s7PLC = new S7PLC(elpcType, hostname, port, rack, slot, pduLength); + this.dataPointFactory = dataPointFactory; + } + + public List read(final @NotNull S7DataType type, final @NotNull List addresses) { + if(log.isTraceEnabled()) { + log.trace("Reading data from addresses {} with type {}", addresses, type); + } + switch (type) { + case BOOL: return createDatapointsFromAddressesAndValues(dataPointFactory, addresses, s7PLC.readBoolean(addresses)); + case BYTE: return addresses.stream().map(address -> dataPointFactory + .create(address, s7PLC.readByte(address))) + .collect(Collectors.toList()); + case WORD: return createDatapointsFromAddressesAndValues(dataPointFactory, addresses, readBytes(addresses, 2)); + case DWORD: return createDatapointsFromAddressesAndValues(dataPointFactory, addresses, readBytes(addresses, 4)); + case LWORD: return createDatapointsFromAddressesAndValues(dataPointFactory, addresses, readBytes(addresses, 8)); + case USINT: return addresses.stream().map(address -> dataPointFactory + .create(address, Byte.toUnsignedInt(s7PLC.readByte(address)))) + .collect(Collectors.toList()); + case UINT: return createDatapointsFromAddressesAndValues(dataPointFactory, addresses, s7PLC.readUInt16(addresses)); + case UDINT: return createDatapointsFromAddressesAndValues(dataPointFactory, addresses, s7PLC.readUInt32(addresses)); + case ULINT: return addresses.stream() + .map(address -> dataPointFactory.create(address, new BigInteger(Long.toUnsignedString(s7PLC.readInt64(address))))) + .collect(Collectors.toList()); + case SINT: return addresses.stream().map(address -> dataPointFactory + .create(address, ((Byte)s7PLC.readByte(address)).shortValue())) + .collect(Collectors.toList()); + case INT: return createDatapointsFromAddressesAndValues(dataPointFactory, addresses, s7PLC.readInt16(addresses)); + case DINT: return createDatapointsFromAddressesAndValues(dataPointFactory, addresses, s7PLC.readInt32(addresses)); + case LINT: return createDatapointsFromAddressesAndValues(dataPointFactory, addresses, s7PLC.readInt64(addresses)); + case REAL: return createDatapointsFromAddressesAndValues(dataPointFactory, addresses, s7PLC.readFloat32(addresses)); + case LREAL: return createDatapointsFromAddressesAndValues(dataPointFactory, addresses, s7PLC.readFloat64(addresses)); + case CHAR: return addresses.stream().map(address -> dataPointFactory + .create(address, s7PLC.readByte(address))) + .collect(Collectors.toList()); + case WCHAR: return addresses.stream() + .map(address -> { + final byte[] bytes = s7PLC.readByte(address, 2); + final char charValue = (char) ((bytes[0] & 0xff) << 8 | (bytes[1] & 0xff)); + return dataPointFactory.create(address, charValue); + }) + .collect(Collectors.toList()); + case STRING: + case WSTRING: return createDatapointsFromAddressesAndValues(dataPointFactory, addresses, addresses.stream().map(s7PLC::readString).collect(Collectors.toList())); + case TIME: return createDatapointsFromAddressesAndValues(dataPointFactory, addresses, addresses.stream().map(s7PLC::readTime).collect(Collectors.toList())); + case LTIME: return createDatapointsFromAddressesAndValues(dataPointFactory, addresses, s7PLC.readInt64(addresses)); + case DATE: return createDatapointsFromAddressesAndValues(dataPointFactory, addresses, addresses.stream().map(s7PLC::readDate).collect(Collectors.toList())); + case TOD: return createDatapointsFromAddressesAndValues(dataPointFactory, addresses, addresses.stream().map(s7PLC::readTimeOfDay).collect(Collectors.toList())); + case LTOD: return addresses.stream() + .map(address -> dataPointFactory.create(address, new BigInteger(Long.toUnsignedString(s7PLC.readInt64(address))))) + .collect(Collectors.toList()); + case DT: return addresses.stream() + .map(address -> dataPointFactory.create(address, s7PLC.readDate(address))) + .collect(Collectors.toList()); + case LDT:return addresses.stream() + .map(address -> dataPointFactory.create(address, new BigInteger(Long.toUnsignedString(s7PLC.readInt64(address))))) + .collect(Collectors.toList()); + case DTL: return createDatapointsFromAddressesAndValues(dataPointFactory, addresses, addresses.stream().map(s7PLC::readDTL).collect(Collectors.toList())); + default: { + log.error("Unspported tag-type {} at address {}", type, addresses); + throw new IllegalArgumentException("Unspported tag-type " + type + " at address " + addresses); + } + } + } + + public List readBytes(final List addresses, final int count) { + return addresses.stream().map(address -> s7PLC.readByte(address, count)).collect(Collectors.toList()); + } + + public static List createDatapointsFromAddressesAndValues(final @NotNull DataPointFactory dataPointFactory, final @NotNull List addresses, final @NotNull List values) { + return IntStream + .range(0, addresses.size()) + .mapToObj(i -> dataPointFactory.create(addresses.get(i), values.get(i))) + .collect(Collectors.toList()); + } + + public void connect() { + s7PLC.connect(); + } + + public void disconnect() { + s7PLC.close(); + } + + public static EPlcType getEplcType(final @NotNull S7AdapterConfig.ControllerType controllerType) { + switch (controllerType) { + case S7_200: return EPlcType.S200; + case S7_200_SMART: return EPlcType.S200_SMART; + case S7_300: return EPlcType.S300; + case S7_400: return EPlcType.S400; + case S7_1200: return EPlcType.S1200; + case S7_1500: return EPlcType.S1500; + default: throw new IllegalArgumentException("Unsupported controller type: " + controllerType); + } + } +} diff --git a/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/S7ProtocolAdapter.java b/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/S7ProtocolAdapter.java new file mode 100644 index 0000000000..9be7773b12 --- /dev/null +++ b/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/S7ProtocolAdapter.java @@ -0,0 +1,160 @@ +/* + * Copyright 2023-present HiveMQ GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.edge.adapters.s7; + +import com.github.xingshuangs.iot.protocol.s7.enums.EPlcType; +import com.hivemq.adapter.sdk.api.ProtocolAdapterInformation; +import com.hivemq.adapter.sdk.api.data.DataPoint; +import com.hivemq.adapter.sdk.api.model.ProtocolAdapterInput; +import com.hivemq.adapter.sdk.api.model.ProtocolAdapterStartInput; +import com.hivemq.adapter.sdk.api.model.ProtocolAdapterStartOutput; +import com.hivemq.adapter.sdk.api.model.ProtocolAdapterStopInput; +import com.hivemq.adapter.sdk.api.model.ProtocolAdapterStopOutput; +import com.hivemq.adapter.sdk.api.polling.PollingInput; +import com.hivemq.adapter.sdk.api.polling.PollingOutput; +import com.hivemq.adapter.sdk.api.polling.PollingProtocolAdapter; +import com.hivemq.adapter.sdk.api.state.ProtocolAdapterState; +import com.hivemq.edge.adapters.s7.config.S7AdapterConfig; +import com.hivemq.edge.adapters.s7.config.S7DataType; +import com.hivemq.edge.adapters.s7.config.S7Tag; +import com.hivemq.edge.adapters.s7.config.S7TagDefinition; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * @author HiveMQ Adapter Generator + */ +public class S7ProtocolAdapter implements PollingProtocolAdapter { + + private static final Logger log = LoggerFactory.getLogger(S7ProtocolAdapter.class); + + private final ProtocolAdapterInformation adapterInformation; + private final S7AdapterConfig adapterConfig; + private final ProtocolAdapterState protocolAdapterState; + private final S7Client s7Client; + private final Map tags; + private final @NotNull String adapterId; + + private final Map dataPoints; + + public S7ProtocolAdapter( + final @NotNull ProtocolAdapterInformation adapterInformation, + final @NotNull ProtocolAdapterInput input) { + this.adapterId = input.getAdapterId(); + this.adapterInformation = adapterInformation; + this.adapterConfig = input.getConfig(); + this.tags = input.getTags().stream().map(t -> (S7Tag)t).collect(Collectors.toMap(S7Tag::getName, Function.identity())); + this.protocolAdapterState = input.getProtocolAdapterState(); + final EPlcType eplcType = S7Client.getEplcType(adapterConfig.getControllerType()); + s7Client = new S7Client( + eplcType, + adapterConfig.getHost(), + adapterConfig.getPort(), + Objects.requireNonNullElse(adapterConfig.getRemoteRack(), eplcType.getRack()), + Objects.requireNonNullElse(adapterConfig.getRemoteSlot(), eplcType.getSlot()), + Objects.requireNonNullElse(adapterConfig.getPduLength(), eplcType.getPduLength()), + input.adapterFactories().dataPointFactory()); + this.dataPoints = new ConcurrentHashMap<>(); + } + + @Override + public int getPollingIntervalMillis() { + return adapterConfig.getS7ToMqttConfig().getPollingIntervalMillis(); + } + + @Override + public int getMaxPollingErrorsBeforeRemoval() { + return adapterConfig.getS7ToMqttConfig().getMaxPollingErrorsBeforeRemoval(); + } + + @Override + public @NotNull String getId() { + return adapterId; + } + + @Override + public void start( + @NotNull final ProtocolAdapterStartInput input, + @NotNull final ProtocolAdapterStartOutput output) { + log.info("Connecting to {}@{}:{}", adapterConfig.getControllerType(), adapterConfig.getHost(), adapterConfig.getPort()); + try { + s7Client.connect(); + protocolAdapterState.setConnectionStatus(ProtocolAdapterState.ConnectionStatus.CONNECTED); + output.startedSuccessfully(); + } catch (final Exception e) { + String msg = "Unable to connect to " + adapterConfig.getControllerType() + "@" + adapterConfig.getHost() + ":" + adapterConfig.getPort(); + protocolAdapterState.setErrorConnectionStatus(e, msg); + output.failStart(e, msg); + } + } + + @Override + public void stop(@NotNull final ProtocolAdapterStopInput input, @NotNull final ProtocolAdapterStopOutput output) { + log.info("Closing connection to {}@{}:{}", adapterConfig.getControllerType(), adapterConfig.getHost(), adapterConfig.getPort()); + try { + s7Client.disconnect(); + protocolAdapterState.setConnectionStatus(ProtocolAdapterState.ConnectionStatus.DISCONNECTED); + output.stoppedSuccessfully(); + } catch (final Exception e) { + final String msg = "Unable to disconnect from " + adapterConfig.getControllerType() + "@" + adapterConfig.getHost() + ":" + adapterConfig.getPort(); + protocolAdapterState.setErrorConnectionStatus(e, msg); + output.failStop(e, msg); + } + } + + @Override + public void poll(@NotNull final PollingInput pollingInput, @NotNull final PollingOutput pollingOutput) { + S7Tag tagToRead = tags.get(pollingInput.getPollingContext().getTagName()); + S7TagDefinition tagDefinition = tagToRead.getDefinition(); + //Every S7 address starts with a % but the iot-communications lib doesn't like it, so we are stripping it. + final String tagAddress = tagDefinition.getAddress().replace("%",""); + final DataPoint dataPoint = s7Client.read(tagDefinition.getDataType(), List.of(tagAddress)).get(0); + + if(adapterConfig.getS7ToMqttConfig().getPublishChangedDataOnly()) { + if(dataPoints.containsKey(tagAddress)) { + final DataPoint existingDataPoint = dataPoints.get(tagAddress); + if(existingDataPoint != null && existingDataPoint.equals(dataPoint)) { + if (log.isTraceEnabled()){ + log.trace("Skipping sending for {} because publishChangedDataOnly=true", tagAddress); + } + } else { + dataPoints.put(tagAddress, dataPoint); + pollingOutput.addDataPoint(dataPoint); + } + } else { + dataPoints.put(tagAddress, dataPoint); + pollingOutput.addDataPoint(dataPoint); + } + } else { + pollingOutput.addDataPoint(dataPoint); + } + + pollingOutput.finish(); + } + + @Override + public @NotNull ProtocolAdapterInformation getProtocolAdapterInformation() { + return adapterInformation; + } +} diff --git a/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/S7ProtocolAdapterFactory.java b/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/S7ProtocolAdapterFactory.java new file mode 100644 index 0000000000..8c953d6b6d --- /dev/null +++ b/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/S7ProtocolAdapterFactory.java @@ -0,0 +1,45 @@ +/* + * Copyright 2023-present HiveMQ GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.edge.adapters.s7; + +import com.hivemq.adapter.sdk.api.ProtocolAdapter; +import com.hivemq.adapter.sdk.api.ProtocolAdapterInformation; +import com.hivemq.adapter.sdk.api.factories.ProtocolAdapterFactory; +import com.hivemq.adapter.sdk.api.factories.ProtocolAdapterFactoryInput; +import com.hivemq.adapter.sdk.api.model.ProtocolAdapterInput; +import com.hivemq.edge.adapters.s7.config.S7AdapterConfig; +import org.jetbrains.annotations.NotNull; + +/** + * @author HiveMQ Adapter Generator + */ +public class S7ProtocolAdapterFactory implements ProtocolAdapterFactory { + + public S7ProtocolAdapterFactory(@NotNull final ProtocolAdapterFactoryInput input) {} + + @Override + public @NotNull ProtocolAdapterInformation getInformation() { + return S7ProtocolAdapterInformation.INSTANCE; + } + + @Override + public @NotNull ProtocolAdapter createAdapter( + @NotNull final ProtocolAdapterInformation adapterInformation, + @NotNull final ProtocolAdapterInput input) { + return new S7ProtocolAdapter(adapterInformation, input); + } + +} diff --git a/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/S7ProtocolAdapterInformation.java b/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/S7ProtocolAdapterInformation.java new file mode 100644 index 0000000000..58ba2f1a8b --- /dev/null +++ b/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/S7ProtocolAdapterInformation.java @@ -0,0 +1,144 @@ +/* + * Copyright 2023-present HiveMQ GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.edge.adapters.s7; + +import com.hivemq.adapter.sdk.api.ProtocolAdapterCapability; +import com.hivemq.adapter.sdk.api.ProtocolAdapterCategory; +import com.hivemq.adapter.sdk.api.ProtocolAdapterInformation; +import com.hivemq.adapter.sdk.api.ProtocolAdapterTag; +import com.hivemq.adapter.sdk.api.config.ProtocolSpecificAdapterConfig; +import com.hivemq.adapter.sdk.api.tag.Tag; +import com.hivemq.edge.adapters.s7.config.S7AdapterConfig; +import com.hivemq.edge.adapters.s7.config.S7Tag; +import org.apache.commons.io.IOUtils; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.EnumSet; +import java.util.List; + +/** + * @author HiveMQ Adapter Generator + */ +public class S7ProtocolAdapterInformation implements ProtocolAdapterInformation { + + public static final ProtocolAdapterInformation INSTANCE = new S7ProtocolAdapterInformation(); + private static final @NotNull Logger log = LoggerFactory.getLogger(S7ProtocolAdapterInformation.class); + public static final String PROTOCOL_ID = "s7-new"; + private static final int CURRENT_CONFIG_VERSION = 1; + + + protected S7ProtocolAdapterInformation() { + } + + @Override + public int getCurrentConfigVersion() { + return CURRENT_CONFIG_VERSION; + } + + @Override + public @NotNull String getProtocolName() { + return "S7"; + } + + @Override + public @NotNull String getProtocolId() { + return PROTOCOL_ID; + } + + @Override + public @NotNull String getDisplayName() { + return "S7 to MQTT Protocol Adapter"; + } + + @Override + public @NotNull String getDescription() { + return "Connects HiveMQ Edge to S7-200, S7-200 Smart, S7-300, S7-400, S7-1200, S7-1500, reading data from the PLC into MQTT."; + } + + @Override + public @NotNull String getUrl() { + return "https://docs.hivemq.com/hivemq-edge/protocol-adapters.html#s7-adapter"; + } + + @Override + public @NotNull String getVersion() { + return "${edge-version}"; + } + + @Override + public @NotNull String getLogoUrl() { + return "/images/s7-icon.png"; + + } + + @Override + public @NotNull String getAuthor() { + return "HiveMQ"; + } + + @Override + public ProtocolAdapterCategory getCategory() { + return ProtocolAdapterCategory.INDUSTRIAL; + } + + @Override + public List getTags() { + return List.of(ProtocolAdapterTag.TCP, + ProtocolAdapterTag.AUTOMATION, + ProtocolAdapterTag.FACTORY); + } + + @Override + public @NotNull EnumSet getCapabilities() { + return EnumSet.of(ProtocolAdapterCapability.READ); + } + + @Override + public @Nullable String getUiSchema() { + try (final InputStream is = this.getClass() + .getClassLoader() + .getResourceAsStream("s7-adapter-ui-schema.json")) { + if (is == null) { + log.warn("The UISchema for the S7 Adapter could not be loaded from resources: Not found."); + return null; + } + return IOUtils.toString(is, StandardCharsets.UTF_8); + } catch (Exception e) { + log.warn("The UISchema for the S7 Adapter could not be loaded from resources:", e); + return null; + } + } + + @Override + public @NotNull Class tagConfigurationClass() { + return S7Tag.class; + } + + @Override + public @NotNull Class configurationClassNorthbound() { + return S7AdapterConfig.class; + } + + @Override + public @NotNull Class configurationClassNorthAndSouthbound() { + return S7AdapterConfig.class; + } +} diff --git a/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/config/S7AdapterConfig.java b/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/config/S7AdapterConfig.java new file mode 100644 index 0000000000..247f6de43d --- /dev/null +++ b/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/config/S7AdapterConfig.java @@ -0,0 +1,167 @@ +/* + * Copyright 2023-present HiveMQ GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.edge.adapters.s7.config; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.hivemq.adapter.sdk.api.annotations.ModuleConfigField; +import com.hivemq.adapter.sdk.api.config.ProtocolSpecificAdapterConfig; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; + +public class S7AdapterConfig implements ProtocolSpecificAdapterConfig { + + private static final int PORT_MIN = 1; + private static final int PORT_MAX = 65535; + + public static final int DEFAULT_S7_PORT = 102; + public static final String DEFAULT_CONTROLER_TYPE = "S7_300"; + + public static final String PROPERTY_PORT = "port"; + public static final String PROPERTY_HOST = "host"; + public static final String PROPERTY_CONTROLLER_TYPE = "controllerType"; + public static final String PROPERTY_REMOTE_RACK = "remoteRack"; + public static final String PROPERTY_REMOTE_SLOT = "remoteSlot"; + public static final String PROPERTY_PDU_LENGTH = "pduLength"; + public static final String PROPERTY_S_7_TO_MQTT = "s7ToMqtt"; + + public enum ControllerType { + S7_200, + S7_200_SMART, + S7_300, + S7_400, + S7_1200, + S7_1500, + SINUMERIK_828D + } + + @JsonProperty(value = PROPERTY_PORT) + @ModuleConfigField(title = "Port", + description = "The port number on the device to connect to", + required = true, + defaultValue = "" + DEFAULT_S7_PORT, + numberMin = PORT_MIN, + numberMax = PORT_MAX) + private final @NotNull Integer port; + + @JsonProperty(value = PROPERTY_HOST, required = true) + @ModuleConfigField(title = "Host", + description = "IP Address or hostname of the device you wish to connect to", + required = true, + format = ModuleConfigField.FieldType.HOSTNAME) + private final @NotNull String host; + + @JsonProperty(value = PROPERTY_CONTROLLER_TYPE, required = true) + @ModuleConfigField(title = "S7 Controller Type", + description = "The type of the S7 Controller", + required = true, + defaultValue = DEFAULT_CONTROLER_TYPE) + private final @NotNull S7AdapterConfig.ControllerType controllerType; + + @JsonProperty(PROPERTY_REMOTE_RACK) + @ModuleConfigField(title = "Remote Rack", + description = "Rack value for the remote main CPU (PLC).") + private final Integer remoteRack; + + @JsonProperty(PROPERTY_REMOTE_SLOT) + @ModuleConfigField(title = "Remote Slot", + description = "Slot value for the remote main CPU (PLC).") + private final Integer remoteSlot; + + @JsonProperty(PROPERTY_PDU_LENGTH) + @ModuleConfigField(title = "PDU length", + description = "") + private final Integer pduLength; + + @JsonProperty(value = PROPERTY_S_7_TO_MQTT, required = true) + @ModuleConfigField(title = "S7 To MQTT Config", + description = "The configuration for a data stream from S7 to MQTT") + private final @NotNull S7ToMqttConfig s7ToMqttConfig; + + @JsonCreator + public S7AdapterConfig( + @JsonProperty(value = PROPERTY_PORT) final Integer port, + @JsonProperty(value = PROPERTY_HOST, required = true) final @NotNull String host, + @JsonProperty(value = PROPERTY_CONTROLLER_TYPE, required = true) final @NotNull ControllerType controllerType, + @JsonProperty(value = PROPERTY_REMOTE_RACK) final @Nullable Integer remoteRack, + @JsonProperty(value = PROPERTY_REMOTE_SLOT) final @Nullable Integer remoteSlot, + @JsonProperty(value = PROPERTY_PDU_LENGTH) final @Nullable Integer pduLength, + @JsonProperty(value = PROPERTY_S_7_TO_MQTT) final @Nullable S7ToMqttConfig s7ToMqttConfig) { + this.host = host; + this.controllerType = controllerType; + this.port = Objects.requireNonNullElse(port, DEFAULT_S7_PORT); + this.remoteRack = remoteRack; + this.remoteSlot = remoteSlot; + this.pduLength = pduLength; + + if (s7ToMqttConfig == null) { + this.s7ToMqttConfig = new S7ToMqttConfig(null, null, null); + } else { + this.s7ToMqttConfig = s7ToMqttConfig; + } + } + + public int getPort() { + return port; + } + + public @Nullable Integer getRemoteRack() { + return remoteRack; + } + + public @Nullable Integer getRemoteSlot() { + return remoteSlot; + } + + public @Nullable Integer getPduLength() { + return pduLength; + } + + public @NotNull ControllerType getControllerType() { + return controllerType; + } + + public @NotNull String getHost() { + return host; + } + + public @NotNull S7ToMqttConfig getS7ToMqttConfig() { + return s7ToMqttConfig; + } + + @Override + public String toString() { + return "S7AdapterConfig{" + + "port=" + + port + + ", host='" + + host + + '\'' + + ", controllerType=" + + controllerType + + ", remoteRack=" + + remoteRack + + ", remoteSlot=" + + remoteSlot + + ", pduLength=" + + pduLength + + ", s7ToMqttConfig=" + + s7ToMqttConfig + + '}'; + } +} diff --git a/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/config/S7DataType.java b/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/config/S7DataType.java new file mode 100644 index 0000000000..0968616536 --- /dev/null +++ b/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/config/S7DataType.java @@ -0,0 +1,102 @@ +/* + * Copyright 2023-present HiveMQ GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.edge.adapters.s7.config; + +import org.jetbrains.annotations.NotNull; + +import java.math.BigInteger; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static com.hivemq.edge.adapters.s7.config.S7Versions.S7_1200; +import static com.hivemq.edge.adapters.s7.config.S7Versions.S7_1500; +import static com.hivemq.edge.adapters.s7.config.S7Versions.S7_300; +import static com.hivemq.edge.adapters.s7.config.S7Versions.S7_400; + +/** + * Documented here: + * https://support.industry.siemens.com/cs/mdm/109054417?c=69695636619&lc=en-GE + */ +public enum S7DataType { + BOOL(Boolean.class, 1, List.of(S7_300, S7_400, S7_1200, S7_1500), "Boolean", "https://support.industry.siemens.com/cs/mdm/109054417?c=46422035979&lc=en-GE"), + BYTE(Byte.class, 8, List.of(S7_300, S7_400, S7_1200, S7_1500), "Byte", "https://support.industry.siemens.com/cs/mdm/109054417?c=56595553163&lc=en-GE"), + WORD(Short.class, 16, List.of(S7_300, S7_400, S7_1200, S7_1500), "Word", "https://support.industry.siemens.com/cs/mdm/109054417?c=56595472523&lc=en-GE"), + DWORD(Integer.class, 32, List.of(S7_300, S7_400, S7_1200, S7_1500), "Double Word", "https://support.industry.siemens.com/cs/mdm/109054417?c=56595322763&lc=en-GE"), + LWORD(Long.class, 64, List.of(S7_1500), "Long Word", "https://support.industry.siemens.com/cs/mdm/109054417?c=56595507211&lc=en-GE"), + USINT(Integer.class, 8, List.of(S7_1200, S7_1500), "Unsigned Short Integer", "https://support.industry.siemens.com/cs/mdm/109054417?c=46521647883&lc=en-GE"), + UINT(Integer.class, 16, List.of(S7_1200, S7_1500), "Unsigned Integer", "https://support.industry.siemens.com/cs/mdm/109054417?c=46521834123&lc=en-GE"), + UDINT(Long.class, 32, List.of(S7_1200, S7_1500), "Unsigned Double Integer", "https://support.industry.siemens.com/cs/mdm/109054417?c=46521930763&lc=en-GE"), + ULINT(BigInteger.class, 64, List.of(S7_1500), "Unsigned Long Integer", "https://support.industry.siemens.com/cs/mdm/109054417?c=59653945739&lc=en-GE"), + SINT(Short.class, 8, List.of(S7_1200, S7_1500), "Short Integer", "https://support.industry.siemens.com/cs/mdm/109054417?c=68894861835&lc=en-GE"), + INT(Short.class, 16, List.of(S7_300, S7_400, S7_1200, S7_1500),"Integer", "https://support.industry.siemens.com/cs/mdm/109054417?c=63679745547&lc=en-GE"), + DINT(Integer.class, 32, List.of(S7_300, S7_400, S7_1200, S7_1500), "Double Integer", "https://support.industry.siemens.com/cs/mdm/109054417?c=46521869963&lc=en-GE"), + LINT(Long.class, 64, List.of(S7_1500), "Long Integer", "https://support.industry.siemens.com/cs/mdm/109054417?c=66825552267&lc=en-GE"), + REAL(Float.class, 32, List.of(S7_300, S7_400, S7_1200, S7_1500), "Real", "https://support.industry.siemens.com/cs/mdm/109054417?c=68826794251&lc=en-GE"), + LREAL(Double.class, 64, List.of(S7_1200, S7_1500), "Long Real", "https://support.industry.siemens.com/cs/mdm/109054417?c=68826903691&lc=en-GE"), + + CHAR(Byte.class, 8, List.of(S7_300, S7_400, S7_1200, S7_1500), "Character", "https://support.industry.siemens.com/cs/mdm/109054417?c=57152595083&lc=en-GE"), + WCHAR(Character.class, 16, List.of(S7_1200, S7_1500), "Wide Character", "https://support.industry.siemens.com/cs/mdm/109054417?c=10488733835&lc=en-GE"), + STRING(String.class, -1, List.of(S7_300, S7_400, S7_1200, S7_1500), "String, 0 to 254 characters only ASCII", "https://support.industry.siemens.com/cs/mdm/109054417?c=63689840011&lc=en-GE"), + WSTRING(String.class, -1, List.of(S7_1200, S7_1500), "Wide String, 0 to 254 characters only Unicode", "https://support.industry.siemens.com/cs/mdm/109054417?c=61472021771&lc=en-GE"), // + + TIME(Long.class, 32, List.of(S7_300, S7_400, S7_1200, S7_1500), "IEC Time (ms)", "https://support.industry.siemens.com/cs/mdm/109054417?c=61085966091&lc=en-GE"), + LTIME(Long.class, 64, List.of(S7_1500), "IEC Time (ns)", "https://support.industry.siemens.com/cs/mdm/109054417?c=61410814475&lc=en-GE"), + //TODO S5TIME https://support.industry.siemens.com/cs/mdm/109054417?c=63689295627&lc=en-GE + + DATE(Short.class, 8, List.of(S7_300, S7_400, S7_1200, S7_1500), "IEC Date, since 01-01-1990 (Year-Month-Day)", "https://support.industry.siemens.com/cs/mdm/109054417?c=46522046859&lc=en-GE"), + TOD(Long.class, 32, List.of(S7_300, S7_400, S7_1200, S7_1500), "Time Of Day (hours:minutes:seconds.milliseconds)", "https://support.industry.siemens.com/cs/mdm/109054417?c=64869849355&lc=en-GE"), + LTOD(BigInteger.class, 64, List.of(S7_1500), "Time-of-day (hours:minutes:seconds.nanoseconds)", "https://support.industry.siemens.com/cs/mdm/109054417?c=64869390987&lc=en-GE"), + DT(LocalDate.class, 64, List.of(S7_1500), "Date and time (year-month-day-hour:minute:second:millisecond)", "https://support.industry.siemens.com/cs/mdm/109054417?c=61473284875&lc=en-GE"), + LDT(LocalDateTime.class, 64, List.of(S7_1500), "Date and time (year-month-day-hour:minute:second:nanoseconds)", "https://support.industry.siemens.com/cs/mdm/109054417?c=71834521483&lc=en-GE"), + DTL(LocalDateTime.class, 64, List.of(S7_1500), "Date and time (year-month-day-hour:minute:second:nanoseconds)", "https://support.industry.siemens.com/cs/mdm/109054417?c=64682916235&lc=en-GE"), + ARRAY(Byte[].class, -1, List.of(S7_300, S7_400, S7_1200, S7_1500), "Array of type", "https://support.industry.siemens.com/cs/mdm/109054417?c=52352205963&lc=en-GE"); + + //RAW_BYTE_ARRAY TODO: it's not an actual type but is there in the old implementation + + S7DataType(final @NotNull Class javaType, final @NotNull int lengthInBits, final @NotNull List availableOn, final @NotNull String description, final @NotNull String docs){ + this.javaType = javaType; + this.lengthInBits = lengthInBits; + this.availableOn = availableOn; + this.description = description; + this.docs = docs; + } + private final @NotNull Class javaType; + private final @NotNull int lengthInBits; + private final @NotNull List availableOn; + private final @NotNull String description; + private final String docs; + + public Class getJavaType() { + return javaType; + } + + public int getLengthInBits() { + return lengthInBits; + } + + public List getAvailableOn() { + return availableOn; + } + + public String getDescription() { + return description; + } + + public String getDocs() { + return docs; + } +} diff --git a/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/config/S7Tag.java b/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/config/S7Tag.java new file mode 100644 index 0000000000..1f4b34ad7d --- /dev/null +++ b/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/config/S7Tag.java @@ -0,0 +1,83 @@ +package com.hivemq.edge.adapters.s7.config; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.hivemq.adapter.sdk.api.annotations.ModuleConfigField; +import com.hivemq.adapter.sdk.api.tag.Tag; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; + +public class S7Tag implements Tag { + + @JsonProperty(value = "name", required = true) + @ModuleConfigField(title = "name", + description = "name of the tag to be used in mappings", + required = true) + private final @NotNull String name; + + @JsonProperty(value = "description", required = true) + @ModuleConfigField(title = "description", + description = "A human readable description of the tag", + required = true) + private final @NotNull String description; + + @JsonProperty(value = "definition", required = true) + @ModuleConfigField(title = "definition", + description = "The actual definition of the tag on the device", + required = true) + private final @NotNull S7TagDefinition definition; + + + public S7Tag( + @JsonProperty(value = "name", required = true) final @NotNull String name, + @JsonProperty(value = "description", required = true) final @NotNull String description, + @JsonProperty(value = "definition", required = true) final @NotNull S7TagDefinition definiton) { + this.name = name; + this.description = description; + this.definition = definiton; + } + + @Override + public @NotNull S7TagDefinition getDefinition() { + return definition; + } + + @Override + public @NotNull String getName() { + return name; + } + + @Override + public @NotNull String getDescription() { + return description; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final S7Tag s7Tag = (S7Tag) o; + return Objects.equals(name, s7Tag.name) && + Objects.equals(description, s7Tag.description) && + Objects.equals(definition, s7Tag.definition); + } + + @Override + public int hashCode() { + return Objects.hash(name, description, definition); + } + + @Override + public String toString() { + return "S7Tag{" + + "name='" + + name + + '\'' + + ", description='" + + description + + '\'' + + ", definition=" + + definition + + '}'; + } +} diff --git a/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/config/S7TagDefinition.java b/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/config/S7TagDefinition.java new file mode 100644 index 0000000000..1c21a6298a --- /dev/null +++ b/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/config/S7TagDefinition.java @@ -0,0 +1,66 @@ +package com.hivemq.edge.adapters.s7.config; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.hivemq.adapter.sdk.api.annotations.ModuleConfigField; +import com.hivemq.adapter.sdk.api.tag.TagDefinition; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; + +public class S7TagDefinition implements TagDefinition { + + @JsonProperty(value = "address", required = true) + @ModuleConfigField(title = "Tag Address", + description = "The well formed address of the tag to read", + required = true) + private final @NotNull String address; + + @JsonProperty(value = "dataType", required = true) + @ModuleConfigField(title = "Data Type", description = "The expected data type of the tag", enumDisplayValues = { + "Boolean", + "Byte", + "Int16", + "UInt16", + "Int32", + "UInt32", + "Int64", + "Real (float 32)", + "LReal (double 64)", + "String", + "Date (DateStamp)", + "Time Of Day (TimeStamp)", + "Date Time (DateTimeStamp)", + "Timing (Duration ms)"}, required = true) + private final @NotNull S7DataType dataType; + + public S7TagDefinition(@JsonProperty(value = "address", required = true) @NotNull final String address,@JsonProperty(value = "dataType", required = true) @NotNull final S7DataType dataType) { + this.address = address; + this.dataType = dataType; + } + + public @NotNull String getAddress() { + return address; + } + + public @NotNull S7DataType getDataType() { + return dataType; + } + + @Override + public boolean equals(final Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final S7TagDefinition that = (S7TagDefinition) o; + return Objects.equals(address, that.address) && dataType == that.dataType; + } + + @Override + public int hashCode() { + return Objects.hash(address, dataType); + } + + @Override + public String toString() { + return "S7TagDefinition{" + "address='" + address + '\'' + ", dataType=" + dataType + '}'; + } +} diff --git a/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/config/S7ToMqttConfig.java b/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/config/S7ToMqttConfig.java new file mode 100644 index 0000000000..e325933d66 --- /dev/null +++ b/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/config/S7ToMqttConfig.java @@ -0,0 +1,90 @@ +/* + * Copyright 2023-present HiveMQ GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.hivemq.edge.adapters.s7.config; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.hivemq.adapter.sdk.api.annotations.ModuleConfigField; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; + +public class S7ToMqttConfig { + + public static final String DEFAULT_POLLING_INTERVAL_MS = "1000"; + public static final String DEFAULT_MAX_POLLING_ERRORS = "10"; + public static final String DEFAULT_PUBLISH_CHANGED_DATA_ONLY = "true"; + + public static final String PROPERTY_POLLING_INTERVAL_MILLIS = "pollingIntervalMillis"; + public static final String PROPERTY_MAX_POLLING_ERRORS_BEFORE_REMOVAL = "maxPollingErrorsBeforeRemoval"; + public static final String PROPERTY_PUBLISH_CHANGED_DATA_ONLY = "publishChangedDataOnly"; + + @JsonProperty(PROPERTY_POLLING_INTERVAL_MILLIS) + @ModuleConfigField(title = "Polling Interval [ms]", + description = "Time in millisecond that this endpoint will be polled", + numberMin = 1, + defaultValue = DEFAULT_POLLING_INTERVAL_MS) + private final int pollingIntervalMillis; + + @JsonProperty(PROPERTY_MAX_POLLING_ERRORS_BEFORE_REMOVAL) + @ModuleConfigField(title = "Max. Polling Errors", + description = "Max. errors polling the endpoint before the polling daemon is stopped (-1 for unlimited retries)", + numberMin = -1, + defaultValue = DEFAULT_MAX_POLLING_ERRORS) + private final int maxPollingErrorsBeforeRemoval; + + @JsonProperty(PROPERTY_PUBLISH_CHANGED_DATA_ONLY) + @ModuleConfigField(title = "Only publish data items that have changed since last poll", + defaultValue = DEFAULT_PUBLISH_CHANGED_DATA_ONLY, + format = ModuleConfigField.FieldType.BOOLEAN) + private final boolean publishChangedDataOnly; + + + public S7ToMqttConfig( + @JsonProperty(value = PROPERTY_POLLING_INTERVAL_MILLIS) final @Nullable Integer pollingIntervalMillis, + @JsonProperty(value = PROPERTY_MAX_POLLING_ERRORS_BEFORE_REMOVAL) final @Nullable Integer maxPollingErrorsBeforeRemoval, + @JsonProperty(value = PROPERTY_PUBLISH_CHANGED_DATA_ONLY) final @Nullable Boolean publishChangedDataOnly) { + this.pollingIntervalMillis = Objects.requireNonNullElse(pollingIntervalMillis, + Integer.valueOf(DEFAULT_POLLING_INTERVAL_MS)); + this.maxPollingErrorsBeforeRemoval = Objects.requireNonNullElse(maxPollingErrorsBeforeRemoval, + Integer.valueOf(DEFAULT_MAX_POLLING_ERRORS)); + this.publishChangedDataOnly = Objects.requireNonNullElse(publishChangedDataOnly, + Boolean.valueOf(DEFAULT_PUBLISH_CHANGED_DATA_ONLY)); + } + + public int getPollingIntervalMillis() { + return pollingIntervalMillis; + } + + public int getMaxPollingErrorsBeforeRemoval() { + return maxPollingErrorsBeforeRemoval; + } + + public boolean getPublishChangedDataOnly() { + return publishChangedDataOnly; + } + + @Override + public String toString() { + return "S7ToMqttConfig{" + + "pollingIntervalMillis=" + + pollingIntervalMillis + + ", maxPollingErrorsBeforeRemoval=" + + maxPollingErrorsBeforeRemoval + + ", publishChangedDataOnly=" + + publishChangedDataOnly + + '}'; + } +} diff --git a/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/config/S7Versions.java b/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/config/S7Versions.java new file mode 100644 index 0000000000..329c0d9656 --- /dev/null +++ b/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/config/S7Versions.java @@ -0,0 +1,9 @@ +package com.hivemq.edge.adapters.s7.config; + +public enum S7Versions { + S7_200, + S7_300, + S7_400, + S7_1200, + S7_1500 +} diff --git a/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/types/BoolType.java b/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/types/BoolType.java new file mode 100644 index 0000000000..4de504a49e --- /dev/null +++ b/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/types/BoolType.java @@ -0,0 +1,31 @@ +package com.hivemq.edge.adapters.s7.types; + +import com.github.xingshuangs.iot.protocol.s7.service.S7PLC; +import com.hivemq.adapter.sdk.api.data.DataPoint; +import com.hivemq.adapter.sdk.api.factories.DataPointFactory; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +public class BoolType { + private final @NotNull DataPointFactory dataPointFactory; + private final @NotNull S7PLC client; + + public BoolType(@NotNull final DataPointFactory dataPointFactory, @NotNull final S7PLC client) { + this.dataPointFactory = dataPointFactory; + this.client = client; + } + + public List read(final @NotNull List addresses) { + return combine(dataPointFactory, addresses, client.readBoolean(addresses)); + } + + public static List combine(final @NotNull DataPointFactory dataPointFactory, final @NotNull List addresses, final @NotNull List values) { + return IntStream + .range(0, addresses.size()) + .mapToObj(i -> dataPointFactory.create(addresses.get(i), values.get(i))) + .collect(Collectors.toList()); + } +} diff --git a/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/types/DatatType.java b/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/types/DatatType.java new file mode 100644 index 0000000000..8715973e29 --- /dev/null +++ b/modules/hivemq-edge-module-s7/src/main/java/com/hivemq/edge/adapters/s7/types/DatatType.java @@ -0,0 +1,11 @@ +package com.hivemq.edge.adapters.s7.types; + +import com.hivemq.adapter.sdk.api.data.DataPoint; +import com.hivemq.edge.adapters.s7.config.S7DataType; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +public interface DatatType { + List read(final @NotNull List addresses); +} diff --git a/modules/hivemq-edge-module-s7/src/main/resources/META-INF/services/com.hivemq.adapter.sdk.api.factories.ProtocolAdapterFactory b/modules/hivemq-edge-module-s7/src/main/resources/META-INF/services/com.hivemq.adapter.sdk.api.factories.ProtocolAdapterFactory new file mode 100644 index 0000000000..0a660cf5a1 --- /dev/null +++ b/modules/hivemq-edge-module-s7/src/main/resources/META-INF/services/com.hivemq.adapter.sdk.api.factories.ProtocolAdapterFactory @@ -0,0 +1 @@ +com.hivemq.edge.adapters.s7.S7ProtocolAdapterFactory diff --git a/modules/hivemq-edge-module-s7/src/main/resources/httpd/images/s7-icon.png b/modules/hivemq-edge-module-s7/src/main/resources/httpd/images/s7-icon.png new file mode 100644 index 0000000000..ac870a9b01 Binary files /dev/null and b/modules/hivemq-edge-module-s7/src/main/resources/httpd/images/s7-icon.png differ diff --git a/modules/hivemq-edge-module-s7/src/main/resources/s7-adapter-ui-schema.json b/modules/hivemq-edge-module-s7/src/main/resources/s7-adapter-ui-schema.json new file mode 100644 index 0000000000..54a0b04673 --- /dev/null +++ b/modules/hivemq-edge-module-s7/src/main/resources/s7-adapter-ui-schema.json @@ -0,0 +1,70 @@ +{ + "ui:tabs" : [ + { + "id" : "coreFields", + "title" : "Connection", + "properties" : [ + "id", + "host", + "port" + ] + }, + { + "id" : "subFields", + "title" : "S7 To MQTT", + "properties" : [ + "s7ToMqtt" + ] + }, + { + "id" : "s7-new", + "title" : "S7 Device", + "properties" : [ + "controllerType", + "remoteRack", + "remoteSlot", + "remoteTsap", + "remoteRack2", + "remoteSlot2" + ] + } + ], + "id" : { + "ui:disabled" : false + }, + "port" : { + "ui:widget" : "updown" + }, + "ui:order" : [ + "id", + "host", + "port", + "*" + ], + "s7ToMqtt" : { + "ui:batchMode" : true, + "ui:order" : [ + "s7ToMqttMappings", + "maxPollingErrorsBeforeRemoval", + "pollingIntervalMillis", + "publishChangedDataOnly", + "*" + ], + "s7ToMqttMappings" : { + "ui:batchMode" : true, + "items" : { + "ui:order" : [ + "tagName", + "tagAddress", + "dataType", + "mqttTopic", + "mqttQos", + "*" + ], + "ui:collapsable" : { + "titleKey" : "mqttTopic" + } + } + } + } +} diff --git a/modules/hivemq-edge-module-s7/src/test/java/com/hivemq/edge/adapters/s7/MainTest.java b/modules/hivemq-edge-module-s7/src/test/java/com/hivemq/edge/adapters/s7/MainTest.java new file mode 100644 index 0000000000..9ff18f419d --- /dev/null +++ b/modules/hivemq-edge-module-s7/src/test/java/com/hivemq/edge/adapters/s7/MainTest.java @@ -0,0 +1,49 @@ +package com.hivemq.edge.adapters.s7; + +import com.github.xingshuangs.iot.protocol.s7.enums.EPlcType; +import com.hivemq.adapter.sdk.api.data.DataPoint; +import com.hivemq.edge.adapters.s7.config.S7AdapterConfig; +import com.hivemq.edge.adapters.s7.config.S7DataType; +import com.hivemq.edge.modules.adapters.data.DataPointImpl; + +import java.util.List; + +public class MainTest { + + public static void main(String[] args) { + s7_300(); + s7_1200(); + s7_1500(); + } + + public static void s7_300() { + final EPlcType eplcType = + S7Client.getEplcType(S7AdapterConfig.ControllerType.S7_300); + S7Client s7Client = new S7Client(eplcType, "172.16.10.53", 102, + eplcType.getRack(), eplcType.getSlot(), eplcType.getPduLength(), DataPointImpl::new); + s7Client.connect(); + final List dataPoint = s7Client.read(S7DataType.BYTE, List.of("IB1")); + System.out.println(dataPoint.get(0).getTagValue()); // => 122 + s7Client.disconnect(); + } + + public static void s7_1200() { + final EPlcType eplcType = + S7Client.getEplcType(S7AdapterConfig.ControllerType.S7_1200); + S7Client s7Client = new S7Client(eplcType, "172.16.10.52", 102, + eplcType.getRack(), eplcType.getSlot(), eplcType.getPduLength(), DataPointImpl::new); + s7Client.connect(); + System.out.println(s7Client.read(S7DataType.BOOL, List.of("I5.0")).get(0).getTagValue()); // => false + s7Client.disconnect(); + } + + public static void s7_1500() { + final EPlcType eplcType = + S7Client.getEplcType(S7AdapterConfig.ControllerType.S7_1500); + S7Client s7Client = new S7Client(eplcType, "172.16.10.51", 102, + eplcType.getRack(), eplcType.getSlot(), eplcType.getPduLength(), DataPointImpl::new); + s7Client.connect(); + System.out.println(s7Client.read(S7DataType.BOOL, List.of("I5.0")).get(0).getTagValue()); // => false + s7Client.disconnect(); + } +} diff --git a/modules/hivemq-edge-module-s7/src/test/java/com/hivemq/edge/adapters/s7/config/S7AdapterConfigTest.java b/modules/hivemq-edge-module-s7/src/test/java/com/hivemq/edge/adapters/s7/config/S7AdapterConfigTest.java new file mode 100644 index 0000000000..7fd14f9694 --- /dev/null +++ b/modules/hivemq-edge-module-s7/src/test/java/com/hivemq/edge/adapters/s7/config/S7AdapterConfigTest.java @@ -0,0 +1,150 @@ +package com.hivemq.edge.adapters.s7.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hivemq.adapter.sdk.api.factories.ProtocolAdapterFactoryInput; +import com.hivemq.adapter.sdk.api.tag.Tag; +import com.hivemq.configuration.entity.HiveMQConfigEntity; +import com.hivemq.configuration.entity.adapter.ProtocolAdapterEntity; +import com.hivemq.configuration.reader.ConfigFileReaderWriter; +import com.hivemq.configuration.reader.ConfigurationFile; +import com.hivemq.edge.adapters.s7.S7ProtocolAdapterFactory; +import com.hivemq.protocols.ProtocolAdapterConfig; +import com.hivemq.protocols.ProtocolAdapterConfigConverter; +import com.hivemq.protocols.ProtocolAdapterFactoryManager; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static com.hivemq.protocols.ProtocolAdapterUtils.createProtocolAdapterMapper; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class S7AdapterConfigTest { + + private final @NotNull ObjectMapper mapper = createProtocolAdapterMapper(new ObjectMapper()); + + @Test + public void convertConfigObject_fullConfig_valid() throws Exception { + final URL resource = getClass().getResource("/s7-adapter-full-config.xml"); + + final ProtocolAdapterConfig protocolAdapterConfig = getProtocolAdapterConfig(resource); + + final S7AdapterConfig config = (S7AdapterConfig) protocolAdapterConfig.getAdapterConfig(); + assertThat(protocolAdapterConfig.missingTags()) + .isEmpty(); + + assertThat(config.getPort()).isEqualTo(1234); + assertThat(config.getHost()).isEqualTo("my.s7-server.com"); + assertThat(config.getControllerType()).isEqualTo(S7AdapterConfig.ControllerType.S7_400); + assertThat(config.getS7ToMqttConfig().getPollingIntervalMillis()).isEqualTo(10); + assertThat(config.getS7ToMqttConfig().getMaxPollingErrorsBeforeRemoval()).isEqualTo(9); + assertThat(config.getS7ToMqttConfig().getPublishChangedDataOnly()).isFalse(); + + assertThat(protocolAdapterConfig.getTags()) + .allSatisfy(t -> { + assertThat(t) + .isInstanceOf(S7Tag.class) + .extracting(Tag::getName, Tag::getDescription, Tag::getDefinition) + .contains("tag-name", "description", new S7TagDefinition("%IB1", S7DataType.INT)); + }); + } + + @Test + public void convertConfigObject_defaults_valid() throws Exception { + final URL resource = getClass().getResource("/s7-adapter-minimal-config.xml"); + final ProtocolAdapterConfig protocolAdapterConfig = getProtocolAdapterConfig(resource); + + final S7AdapterConfig config = (S7AdapterConfig) protocolAdapterConfig.getAdapterConfig(); + assertThat(protocolAdapterConfig.missingTags()) + .isEmpty(); + + assertThat(config).isNotNull(); + assertThat(config.getPort()).isEqualTo(102); + assertThat(config.getHost()).isEqualTo("my.s7-server.com"); + assertThat(config.getControllerType()).isEqualTo(S7AdapterConfig.ControllerType.S7_400); + assertThat(config.getS7ToMqttConfig().getPollingIntervalMillis()).isEqualTo(1000); + assertThat(config.getS7ToMqttConfig().getMaxPollingErrorsBeforeRemoval()).isEqualTo(10); + assertThat(config.getS7ToMqttConfig().getPublishChangedDataOnly()).isTrue(); + + assertThat(protocolAdapterConfig.getTags()) + .allSatisfy(t -> { + assertThat(t) + .isInstanceOf(S7Tag.class) + .extracting(Tag::getName, Tag::getDescription, Tag::getDefinition) + .contains("tag-name", "description", new S7TagDefinition("tag-address", S7DataType.BOOL)); + }); + } + + @Test + public void unconvertConfigObject_full_valid() { + final S7ToMqttConfig pollingContext = new S7ToMqttConfig( + 3000, + 1, + false + ); + + final S7AdapterConfig s7AdapterConfig = new S7AdapterConfig( + 14, + "my.host.com", + S7AdapterConfig.ControllerType.S7_1500, + 1, + 2, + 3, + pollingContext); + + final ProtocolAdapterFactoryInput mockInput = mock(ProtocolAdapterFactoryInput.class); + when(mockInput.isWritingEnabled()).thenReturn(false); + final S7ProtocolAdapterFactory s7ProtocolAdapterFactory = new S7ProtocolAdapterFactory(mockInput); + final Map config = + s7ProtocolAdapterFactory.unconvertConfigObject(mapper, s7AdapterConfig); + + assertThat(config.get("port")).isEqualTo(14); + assertThat(config.get("host")).isEqualTo("my.host.com"); + assertThat(config.get("controllerType")).isEqualTo("S7_1500"); + assertThat(config.get("remoteRack")).isEqualTo(1); + assertThat(config.get("remoteSlot")).isEqualTo(2); + + assertThat((Map) config.get("s7ToMqtt")) + .extracting("pollingIntervalMillis", "maxPollingErrorsBeforeRemoval", "publishChangedDataOnly") + .containsExactly(3000, 1, false); + } + + private @NotNull ProtocolAdapterConfig getProtocolAdapterConfig(final @NotNull URL resource) throws + URISyntaxException { + final File path = Path.of(resource.toURI()).toFile(); + + final HiveMQConfigEntity configEntity = loadConfig(path); + final ProtocolAdapterEntity adapterEntity = configEntity.getProtocolAdapterConfig().get(0); + + final ProtocolAdapterConfigConverter converter = createConverter(); + + return converter.fromEntity(adapterEntity); + } + + private @NotNull ProtocolAdapterConfigConverter createConverter() { + final ProtocolAdapterFactoryInput mockInput = mock(ProtocolAdapterFactoryInput.class); + when(mockInput.isWritingEnabled()).thenReturn(true); + + S7ProtocolAdapterFactory protocolAdapterFactory = new S7ProtocolAdapterFactory(mockInput); + ProtocolAdapterFactoryManager manager = mock(ProtocolAdapterFactoryManager.class); + when(manager.get("s7-new")).thenReturn(Optional.of(protocolAdapterFactory)); + ProtocolAdapterConfigConverter converter = new ProtocolAdapterConfigConverter(manager, mapper); + return converter; + } + + private @NotNull HiveMQConfigEntity loadConfig(final @NotNull File configFile) { + final ConfigFileReaderWriter readerWriter = new ConfigFileReaderWriter( + new ConfigurationFile(configFile), + List.of()); + return readerWriter.applyConfig(); + } + +} diff --git a/modules/hivemq-edge-module-s7/src/test/resources/s7-adapter-full-config.xml b/modules/hivemq-edge-module-s7/src/test/resources/s7-adapter-full-config.xml new file mode 100644 index 0000000000..9da0d5b8b2 --- /dev/null +++ b/modules/hivemq-edge-module-s7/src/test/resources/s7-adapter-full-config.xml @@ -0,0 +1,85 @@ + + + + + s7-new + my-s7-protocol-adapter + + my.s7-server.com + 1234 + S7_400 + 1 + 3 + + 10 + 9 + false + + + + + my/topic + 1 + true + true + MQTTMessagePerSubscription + tag-name + + + name + value1 + + + name + value2 + + + + + my/topic/2 + 1 + true + true + MQTTMessagePerSubscription + tag-name + + + name + value1 + + + name + value2 + + + + + + + tag-name + description + +
%IB1
+ INT +
+
+
+
+
+
diff --git a/modules/hivemq-edge-module-s7/src/test/resources/s7-adapter-minimal-config.xml b/modules/hivemq-edge-module-s7/src/test/resources/s7-adapter-minimal-config.xml new file mode 100644 index 0000000000..7aff5da836 --- /dev/null +++ b/modules/hivemq-edge-module-s7/src/test/resources/s7-adapter-minimal-config.xml @@ -0,0 +1,46 @@ + + + + + + s7-new + my-s7-protocol-adapter + + my.s7-server.com + S7_400 + + + + my/topic + tag-name + + + + + tag-name + description + +
tag-address
+ BOOL +
+
+
+
+
+
diff --git a/settings.gradle.kts b/settings.gradle.kts index 882715f3df..dc9b0c4cdf 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,3 +17,4 @@ includeBuild("./modules/hivemq-edge-module-http") includeBuild("./modules/hivemq-edge-module-modbus") includeBuild("./modules/hivemq-edge-module-opcua") includeBuild("./modules/hivemq-edge-module-file") +includeBuild("./modules/hivemq-edge-module-s7")