diff --git a/.asf.yaml b/.asf.yaml index cab3dfb83..322a356e8 100644 --- a/.asf.yaml +++ b/.asf.yaml @@ -23,7 +23,7 @@ github: description: PLC4X The Industrial IoT adapter homepage: https://plc4x.apache.org/ - dependabot_alerts: true + dependabot_alerts: true dependabot_updates: false labels: @@ -65,11 +65,32 @@ github: edit_comment_discussion: "Re: [D] {title}" delete_comment_discussion: "Re: [D] {title}" + rulesets: + - name: "Default Branch Protection" + type: branch + branches: + includes: + - "~DEFAULT_BRANCH" + - "release/*" + - "rel/*" + excludes: [] + bypass_teams: + - root + - plc4x-committers + restrict_deletion: true + restrict_force_push: true + required_pull_request_reviews: + dismiss_stale_reviews: true + require_last_push_approval: true + required_approving_review_count: 1 + required_linear_history: false + required_signatures: false + required_conversation_resolution: true + notifications: - commits: commits@plc4x.apache.org - issues: issues@plc4x.apache.org - discussions: dev@plc4x.apache.org - pullrequests_status: dev@plc4x.apache.org - pullrequests_comment: dev@plc4x.apache.org - # Send dependabot PRs to commits@ instead + commits: commits@plc4x.apache.org + issues: issues@plc4x.apache.org + discussions: dev@plc4x.apache.org + pullrequests_status: commits@plc4x.apache.org + pullrequests_comment: commits@plc4x.apache.org pullrequests_bot_dependabot: commits@plc4x.apache.org diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..f93e3b1e6 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,49 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. +# + +[*] +charset=utf-8 +end_of_line=lf +insert_final_newline=false +indent_style=space +indent_size=4 + +[{*.yml,*.yaml}] +indent_style=space +indent_size=2 + +[*.xml] +indent_style=space +indent_size=2 + +[*.go] +indent_style = tab + +[*.ftlh] +indent_style = unset + +[*.go.ftlh] +indent_style = tab + +[*.c.ftlh] +indent_style = space + +[*.h.ftlh] +indent_style = space + +[*.java.ftlh] +indent_style = space diff --git a/.github/workflows/c-platform.yml b/.github/workflows/c-platform.yml index bb13a2198..3237c583d 100644 --- a/.github/workflows/c-platform.yml +++ b/.github/workflows/c-platform.yml @@ -34,14 +34,13 @@ on: default: 'false' env: - GRADLE_ENTERPRISE_ACCESS_KEY: ${{ secrets.GE_ACCESS_TOKEN }} + DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} jobs: test: strategy: matrix: - # 8 not anymore supported for building - java: [ 17 ] + java: [ 21 ] os: [ ubuntu-latest, macos-latest, windows-latest ] fail-fast: false runs-on: ${{ matrix.os }} @@ -55,17 +54,17 @@ jobs: run: brew install libpcap - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Java - uses: actions/setup-java@v4 + uses: actions/setup-java@v6 with: - distribution: 'adopt' + distribution: 'temurin' java-package: jdk java-version: ${{ matrix.java }} - name: Cache local Maven repository - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ~/.m2/repository key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} @@ -74,7 +73,7 @@ jobs: - name: Set Platform options id: platform_opts - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v9.0.0 env: OS: ${{ matrix.os }} with: @@ -92,7 +91,7 @@ jobs: - name: Calculate platform suffix id: platform_suffix - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v9.0.0 env: OS: ${{ matrix.os }} with: @@ -106,7 +105,7 @@ jobs: - name: Set extra options id: extra_options - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v9.0.0 env: FORCE_UPDATE: ${{ github.event.inputs.forceUpdates }} with: diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 64ea2bdc3..cec70668c 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -20,16 +20,18 @@ name: 'Generate changelog' on: release: types: [created, edited] + workflow_dispatch: jobs: generate-changelog: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 - - uses: BobAnkh/auto-generate-changelog@v1.2.5 + - uses: BobAnkh/auto-generate-changelog@v1.3.2 with: ACCESS_TOKEN: ${{secrets.GITHUB_TOKEN}} - TYPE: 'feat:Feature,fix:Bug Fixes,docs:Documentation,refactor:Refactor,perf:Performance Improvements' + TYPE: 'feat:Feature,fix:Bug Fixes,docs:Documentation,refactor:Refactor,perf:Performance Improvements,chore(deps):Dependency Updates,chore:Miscellaneous,test:Testing,build:Build System,ci:Continuous Integration,revert:Reverts,style:Style,wip:Work In Progress,release:Release' PATH: 'CHANGELOG.md' + #BRANCH: 'chore/changelog_update' diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index afc5904be..000000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,76 +0,0 @@ -# ---------------------------------------------------------------------------- -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you 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. -# ---------------------------------------------------------------------------- -name: "CodeQL" - -on: - schedule: - - cron: '17 19 * * 3' - -env: - GRADLE_ENTERPRISE_ACCESS_KEY: ${{ secrets.GE_ACCESS_TOKEN }} - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] - ## cpp and csharp not ready yet - #language: [ 'cpp', 'csharp', 'go', 'java', 'python' ] - language: [ 'go', 'java', 'python' ] - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v3 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v3 - - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 63e1ec98a..882e64c2e 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -27,7 +27,7 @@ name: 'Dependency Review' on: [pull_request] env: - GRADLE_ENTERPRISE_ACCESS_KEY: ${{ secrets.GE_ACCESS_TOKEN }} + DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} permissions: contents: read @@ -37,6 +37,6 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Checkout Repository' - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: 'Dependency Review' - uses: actions/dependency-review-action@v4 + uses: actions/dependency-review-action@v5 diff --git a/.github/workflows/go-platform-test-report.yml b/.github/workflows/go-platform-test-report.yml index cb69ad88f..9937ab046 100644 --- a/.github/workflows/go-platform-test-report.yml +++ b/.github/workflows/go-platform-test-report.yml @@ -25,19 +25,18 @@ on: - completed env: - GRADLE_ENTERPRISE_ACCESS_KEY: ${{ secrets.GE_ACCESS_TOKEN }} + DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} jobs: report: strategy: matrix: - # 8 not anymore supported for building - java: [ 17 ] + java: [ 21 ] os: [ ubuntu-latest, macos-latest, windows-latest ] fail-fast: false runs-on: ${{ matrix.os }} steps: - - uses: dorny/test-reporter@v1 + - uses: dorny/test-reporter@v3 with: artifact: "golang-test-report-${{ matrix.os }}" name: "Golang Tests (OS: ${{ matrix.os }})" diff --git a/.github/workflows/go-platform.yml b/.github/workflows/go-platform.yml index bb8b053e1..961e5c913 100644 --- a/.github/workflows/go-platform.yml +++ b/.github/workflows/go-platform.yml @@ -74,14 +74,13 @@ env: PLC4X_TEST_TRACE_DEFAULT_MESSAGE_CODEC_WORKER: ${{ github.event.inputs.traceDefaultMessageCodecWorker }} PLC4X_TEST_TRACE_EXECUTOR_WORKERS: ${{ github.event.inputs.traceExecutorWorkers }} PLC4X_TEST_TEST_TRANSPORT_INSTANCE: ${{ github.event.inputs.traceTestTransportInstance }} - GRADLE_ENTERPRISE_ACCESS_KEY: ${{ secrets.GE_ACCESS_TOKEN }} + DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} jobs: test: strategy: matrix: - # 8 not anymore supported for building - java: [ 17 ] + java: [ 21 ] os: [ ubuntu-latest, macos-latest, windows-latest ] fail-fast: false runs-on: ${{ matrix.os }} @@ -99,17 +98,17 @@ jobs: run: choco install -y nmap - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Java - uses: actions/setup-java@v4 + uses: actions/setup-java@v6 with: - distribution: 'adopt' + distribution: 'temurin' java-package: jdk java-version: ${{ matrix.java }} - name: Cache local Maven repository - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ~/.m2/repository key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} @@ -118,7 +117,7 @@ jobs: - name: Set Platform options id: platform_opts - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v9.0.0 env: OS: ${{ matrix.os }} with: @@ -136,7 +135,7 @@ jobs: - name: Calculate platform suffix id: platform_suffix - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v9.0.0 env: OS: ${{ matrix.os }} with: @@ -150,7 +149,7 @@ jobs: - name: Set extra options id: extra_options - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v9.0.0 env: FORCE_UPDATE: ${{ github.event.inputs.forceUpdates }} with: @@ -162,11 +161,6 @@ jobs: core.setOutput('extra_options', ``) } - - name: Initialize CodeQL - uses: github/codeql-action/init@v3 - with: - languages: go - - name: Check LongPathsEnabled on Windows if: matrix.os == 'windows-latest' run: | @@ -178,7 +172,7 @@ jobs: ./mvnw${{ steps.platform_suffix.outputs.platform_suffix }} ${{ steps.extra_options.outputs.extra_options }} -B -Pwith-go ${{ steps.platform_opts.outputs.platform_opts }} install - name: Upload Test Report (first failure) - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: failure() with: name: "golang-test-report-first-failure-${{ matrix.os }}" @@ -187,13 +181,10 @@ jobs: **/reports/test-out-verbose.log - name: Upload Test Report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: success() || failure() with: name: "golang-test-report-${{ matrix.os }}" path: | **/surefire-reports/go-junit-report.xml **/reports/test-out-verbose.log - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/java-platform-test-report.yml b/.github/workflows/java-platform-test-report.yml index 72962a729..902236603 100644 --- a/.github/workflows/java-platform-test-report.yml +++ b/.github/workflows/java-platform-test-report.yml @@ -25,20 +25,20 @@ on: - completed env: - GRADLE_ENTERPRISE_ACCESS_KEY: ${{ secrets.GE_ACCESS_TOKEN }} + DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} jobs: report: strategy: matrix: - java: [ 11, 17, 21 ] + java: [ 21 ] # TODO: only works on windows due to https://github.com/dorny/test-reporter/issues/249 #os: [ ubuntu-latest, macos-latest, windows-latest ] os: [ windows-latest ] fail-fast: false runs-on: ${{ matrix.os }} steps: - - uses: dorny/test-reporter@v1 + - uses: dorny/test-reporter@v3 with: artifact: "java-test-report-${{ matrix.os }}-java-${{ matrix.java }}" name: "Java Tests (OS: ${{ matrix.os }}, Java: ${{ matrix.java }})" diff --git a/.github/workflows/java-platform.yml b/.github/workflows/java-platform.yml index 25792cad4..bda2bb108 100644 --- a/.github/workflows/java-platform.yml +++ b/.github/workflows/java-platform.yml @@ -36,14 +36,13 @@ on: default: 'false' env: - GRADLE_ENTERPRISE_ACCESS_KEY: ${{ secrets.GE_ACCESS_TOKEN }} + DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} jobs: test: strategy: matrix: - # 8 not anymore supported for building - java: [ 11, 17, 21 ] + java: [ 21 ] os: [ ubuntu-latest, macos-latest, windows-latest ] fail-fast: false runs-on: ${{ matrix.os }} @@ -57,17 +56,17 @@ jobs: run: brew install libpcap - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup Java - uses: actions/setup-java@v4 + uses: actions/setup-java@v6 with: - distribution: 'adopt' + distribution: 'temurin' java-package: jdk java-version: ${{ matrix.java }} - name: Cache local Maven repository - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ~/.m2/repository key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} @@ -76,7 +75,7 @@ jobs: - name: Set Platform options id: platform_opts - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v9.0.0 env: OS: ${{ matrix.os }} with: @@ -94,7 +93,7 @@ jobs: - name: Calculate platform suffix id: platform_suffix - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v9.0.0 env: OS: ${{ matrix.os }} with: @@ -108,7 +107,7 @@ jobs: - name: Set extra options id: extra_options - uses: actions/github-script@v7.0.1 + uses: actions/github-script@v9.0.0 env: FORCE_UPDATE: ${{ github.event.inputs.forceUpdates }} with: @@ -120,18 +119,13 @@ jobs: core.setOutput('extra_options', ``) } - - name: Initialize CodeQL - uses: github/codeql-action/init@v3 - with: - languages: java - # Note: due to github has no concept of recovering from a fail even if we have our fallback below we append a second un-parallel run behind this to be sure that it works with all checks. Only if that fails too we are ok with a "red" (which should be yellow) cross indicating a problem - name: Run mvnw run: | ./mvnw${{ steps.platform_suffix.outputs.platform_suffix }} ${{ steps.extra_options.outputs.extra_options }} -B -Pwith-java ${{ steps.platform_opts.outputs.platform_opts }} install - name: Upload Test Report (first failure) - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: failure() with: name: "java-test-report-first-failure-${{ matrix.os }}-java-${{ matrix.java }}" @@ -140,13 +134,10 @@ jobs: **/failsafe-reports/TEST-*.xml - name: Upload Test Report - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 if: success() || failure() with: name: "java-test-report-${{ matrix.os }}-java-${{ matrix.java }}" path: | **/surefire-reports/TEST-*.xml **/failsafe-reports/TEST-*.xml - -# - name: Perform CodeQL Analysis -# uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 7d4b7c026..10a3ce477 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -34,7 +34,7 @@ permissions: contents: read env: - GRADLE_ENTERPRISE_ACCESS_KEY: ${{ secrets.GE_ACCESS_TOKEN }} + DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} jobs: update_release_draft: @@ -52,7 +52,7 @@ jobs: # echo "GHE_HOST=${GITHUB_SERVER_URL##https:\/\/}" >> $GITHUB_ENV # Drafts your next Release notes as Pull Requests are merged into "master" - - uses: release-drafter/release-drafter@v6 + - uses: release-drafter/release-drafter@v7.7.0 # (Optional) specify config name to use, relative to .github/. Default: release-drafter.yml # with: # config-name: my-config.yml diff --git a/.github/workflows/sast.yaml b/.github/workflows/sast.yaml index 153522b7d..63b7c7870 100644 --- a/.github/workflows/sast.yaml +++ b/.github/workflows/sast.yaml @@ -25,7 +25,7 @@ on: pull_request: env: - GRADLE_ENTERPRISE_ACCESS_KEY: ${{ secrets.GE_ACCESS_TOKEN }} + DEVELOCITY_ACCESS_KEY: ${{ secrets.DEVELOCITY_ACCESS_KEY }} jobs: build: @@ -35,7 +35,7 @@ jobs: if: false steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Run Trivy vulnerability scanner in repo mode uses: aquasecurity/trivy-action@master @@ -47,6 +47,6 @@ jobs: severity: 'CRITICAL' - name: Upload Trivy scan results to GitHub Security tab - uses: github/codeql-action/upload-sarif@v3 + uses: github/codeql-action/upload-sarif@v4.38.1 with: sarif_file: 'trivy-results.sarif' diff --git a/.gitignore b/.gitignore index 9b01b0648..0965dd7b4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,92 @@ -.idea -target +# User-specific stuff: +.idea/*.xml +*/.idea/*.xml +*.iml +.vscode/** +.java-version +.attach_pid* + + + +# Sensitive or high-churn files: +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.xml +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml + +### Java template +# Compiled class file +*.class + +# Log file +*.log + +# Package Files # +*.jar +*.war +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + +# Asciidoctor plugin +.asciidoctor + +### Maven template +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties + +### Eclipse template +.metadata +*.tmp +*.bak +*.swp +#*~.nib +local.properties +.settings/ +.loadpath +.recommenders +.project +.classpath + +# Locally stored "Eclipse launch configurations" +*.launch + +# STS (Spring Tool Suite) +.springBeans + +# Scala IDE specific (Scala & Java development for Eclipse) +.cache-main +.scala_dependencies +.worksheet + +# Emacs backup files +#*~ +.scannerwork + +spock-reports + # Develocity .mvn/.gradle-enterprise/ +.mvn/.develocity/ /plc4j/tools/ui/application/derby.log # Source bundle from PLC4C /plc4c/plc4c/ + +# Release build output +/out/ diff --git a/.mvn/.develocity/develocity-workspace-id b/.mvn/.develocity/develocity-workspace-id new file mode 100644 index 000000000..0fc67528b --- /dev/null +++ b/.mvn/.develocity/develocity-workspace-id @@ -0,0 +1 @@ +hr5gpb6cirbkpkbdnouiqb5ca4 \ No newline at end of file diff --git a/.mvn/develocity.xml b/.mvn/develocity.xml new file mode 100644 index 000000000..adb59392e --- /dev/null +++ b/.mvn/develocity.xml @@ -0,0 +1,47 @@ + + + + plc4x-extras + + https://develocity.apache.org + false + + + #{isFalse(env['GITHUB_ACTIONS'])} + + + + + + + #{{'0.0.0.0'}} + + + + + #{isFalse(env['GITHUB_ACTIONS'])} + + + false + + + \ No newline at end of file diff --git a/.mvn/extensions.xml b/.mvn/extensions.xml index 83bf992c8..aa0f6be45 100644 --- a/.mvn/extensions.xml +++ b/.mvn/extensions.xml @@ -15,15 +15,17 @@ specific language governing permissions and limitations under the License. --> - + com.gradle - gradle-enterprise-maven-extension - 1.20.1 + develocity-maven-extension + 2.5.0 com.gradle common-custom-user-data-maven-extension - 2 + 2.4.0 \ No newline at end of file diff --git a/.mvn/gradle-enterprise.xml b/.mvn/gradle-enterprise.xml deleted file mode 100644 index 62bb205a3..000000000 --- a/.mvn/gradle-enterprise.xml +++ /dev/null @@ -1,46 +0,0 @@ - - - - - https://ge.apache.org - false - - - - true - true - true - - #{isFalse(env['GITHUB_ACTIONS'])} - ALWAYS - true - - #{{'0.0.0.0'}} - - - - - #{isFalse(env['GITHUB_ACTIONS'])} - - - false - - - \ No newline at end of file diff --git a/.mvn/maven.config b/.mvn/maven.config new file mode 100644 index 000000000..5e3af0ef2 --- /dev/null +++ b/.mvn/maven.config @@ -0,0 +1,2 @@ +-P ?with-dotnet,?with-python,?update-generated-code,?reactor-with-extras,?reactor-with-build-tools +-Dmaven.consumer.pom.flatten=true diff --git a/.mvn/wrapper/MavenWrapperDownloader.java b/.mvn/wrapper/MavenWrapperDownloader.java deleted file mode 100644 index e76d1f324..000000000 --- a/.mvn/wrapper/MavenWrapperDownloader.java +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright 2007-present the original author or 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. - */ -import java.net.*; -import java.io.*; -import java.nio.channels.*; -import java.util.Properties; - -public class MavenWrapperDownloader { - - private static final String WRAPPER_VERSION = "0.5.6"; - /** - * Default URL to download the maven-wrapper.jar from, if no 'downloadUrl' is provided. - */ - private static final String DEFAULT_DOWNLOAD_URL = "https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/" - + WRAPPER_VERSION + "/maven-wrapper-" + WRAPPER_VERSION + ".jar"; - - /** - * Path to the maven-wrapper.properties file, which might contain a downloadUrl property to - * use instead of the default one. - */ - private static final String MAVEN_WRAPPER_PROPERTIES_PATH = - ".mvn/wrapper/maven-wrapper.properties"; - - /** - * Path where the maven-wrapper.jar will be saved to. - */ - private static final String MAVEN_WRAPPER_JAR_PATH = - ".mvn/wrapper/maven-wrapper.jar"; - - /** - * Name of the property which should be used to override the default download url for the wrapper. - */ - private static final String PROPERTY_NAME_WRAPPER_URL = "wrapperUrl"; - - public static void main(String args[]) { - System.out.println("- Downloader started"); - File baseDirectory = new File(args[0]); - System.out.println("- Using base directory: " + baseDirectory.getAbsolutePath()); - - // If the maven-wrapper.properties exists, read it and check if it contains a custom - // wrapperUrl parameter. - File mavenWrapperPropertyFile = new File(baseDirectory, MAVEN_WRAPPER_PROPERTIES_PATH); - String url = DEFAULT_DOWNLOAD_URL; - if(mavenWrapperPropertyFile.exists()) { - FileInputStream mavenWrapperPropertyFileInputStream = null; - try { - mavenWrapperPropertyFileInputStream = new FileInputStream(mavenWrapperPropertyFile); - Properties mavenWrapperProperties = new Properties(); - mavenWrapperProperties.load(mavenWrapperPropertyFileInputStream); - url = mavenWrapperProperties.getProperty(PROPERTY_NAME_WRAPPER_URL, url); - } catch (IOException e) { - System.out.println("- ERROR loading '" + MAVEN_WRAPPER_PROPERTIES_PATH + "'"); - } finally { - try { - if(mavenWrapperPropertyFileInputStream != null) { - mavenWrapperPropertyFileInputStream.close(); - } - } catch (IOException e) { - // Ignore ... - } - } - } - System.out.println("- Downloading from: " + url); - - File outputFile = new File(baseDirectory.getAbsolutePath(), MAVEN_WRAPPER_JAR_PATH); - if(!outputFile.getParentFile().exists()) { - if(!outputFile.getParentFile().mkdirs()) { - System.out.println( - "- ERROR creating output directory '" + outputFile.getParentFile().getAbsolutePath() + "'"); - } - } - System.out.println("- Downloading to: " + outputFile.getAbsolutePath()); - try { - downloadFileFromURL(url, outputFile); - System.out.println("Done"); - System.exit(0); - } catch (Throwable e) { - System.out.println("- Error downloading"); - e.printStackTrace(); - System.exit(1); - } - } - - private static void downloadFileFromURL(String urlString, File destination) throws Exception { - if (System.getenv("MVNW_USERNAME") != null && System.getenv("MVNW_PASSWORD") != null) { - String username = System.getenv("MVNW_USERNAME"); - char[] password = System.getenv("MVNW_PASSWORD").toCharArray(); - Authenticator.setDefault(new Authenticator() { - @Override - protected PasswordAuthentication getPasswordAuthentication() { - return new PasswordAuthentication(username, password); - } - }); - } - URL website = new URL(urlString); - ReadableByteChannel rbc; - rbc = Channels.newChannel(website.openStream()); - FileOutputStream fos = new FileOutputStream(destination); - fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE); - fos.close(); - rbc.close(); - } - -} diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties index 533b8a397..09c249aac 100644 --- a/.mvn/wrapper/maven-wrapper.properties +++ b/.mvn/wrapper/maven-wrapper.properties @@ -15,6 +15,6 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -# -distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.5/apache-maven-3.9.5-bin.zip -wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar +wrapperVersion=4.0.0-rc-6 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/4.0.0-rc-6/apache-maven-4.0.0-rc-6-bin.zip diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..71b116060 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,858 @@ +# CHANGELOG + +## [relt/0.13](https://github.com/apache/plc4x-extras/releases/tag/relt/0.13) - 2025-08-06 15:44:47+00:00 + +## What's Changed +* chore(deps): Bump org.apache.commons:commons-configuration2 from 2.9.0 to 2.10.1 in /plc4j/examples/hello-influx-data-collection by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/1 +* chore(deps): bump com.fasterxml.jackson.core:jackson-databind from 2.16.1 to 2.17.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/2 +* chore(deps): bump io.netty:netty-common from 4.1.107.Final to 4.1.108.Final by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/3 +* chore(deps): bump org.eclipse.jetty:jetty-util from 12.0.5 to 12.0.8 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/4 +* chore(deps): bump commons-io:commons-io from 2.15.1 to 2.16.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/5 +* chore(deps): bump com.microsoft.azure.sdk.iot:iot-device-client from 2.4.1 to 2.5.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/6 +* chore(deps): bump org.codehaus.mojo:extra-enforcer-rules from 1.7.0 to 1.8.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/11 +* chore(deps): bump com.gradle:common-custom-user-data-maven-extension from 1.13 to 2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/10 +* chore(deps): bump io.nats:jnats from 2.17.3 to 2.17.4 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/9 +* chore(deps): bump kotlin.version from 1.9.22 to 1.9.23 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/8 +* chore(deps-dev): bump org.mockito:mockito-inline from 4.11.0 to 5.2.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/7 +* chore(deps): bump commons-io:commons-io from 2.16.0 to 2.16.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/15 +* chore(deps): bump ch.qos.logback:logback-core from 1.5.3 to 1.5.4 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/16 +* chore(deps): bump org.bouncycastle:bcprov-jdk18on from 1.77 to 1.78 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/12 +* chore(deps): bump kafka.version from 3.2.0 to 3.7.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/14 +* chore(deps): bump io.netty:netty-transport from 4.1.107.Final to 4.1.108.Final by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/20 +* chore(deps): bump ch.qos.logback:logback-classic from 1.5.3 to 1.5.4 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/19 +* chore(deps): bump org.bouncycastle:bcmail-jdk18on from 1.77 to 1.78 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/18 +* chore(deps): bump org.bouncycastle:bcpkix-jdk18on from 1.77 to 1.78 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/17 +* chore(deps): bump ch.qos.logback:logback-classic from 1.5.4 to 1.5.5 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/21 +* chore(deps): bump io.nats:jnats from 2.17.4 to 2.17.6 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/23 +* chore(deps): bump jakarta.annotation:jakarta.annotation-api from 2.1.1 to 3.0.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/24 +* chore(deps): bump org.apache.maven.plugins:maven-jar-plugin from 3.3.0 to 3.4.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/25 +* chore(deps): bump slf4j.version from 2.0.12 to 2.0.13 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/22 +* chore(deps): bump io.netty:netty-buffer from 4.1.107.Final to 4.1.109.Final by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/26 +* chore(deps-dev): bump com.fasterxml.jackson.dataformat:jackson-dataformat-yaml from 2.16.1 to 2.17.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/27 +* chore(deps): bump ch.qos.logback:logback-core from 1.5.4 to 1.5.5 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/28 +* chore(deps): bump io.netty:netty-transport from 4.1.108.Final to 4.1.109.Final by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/29 +* chore(deps): bump com.fasterxml.jackson.core:jackson-annotations from 2.16.1 to 2.17.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/30 +* chore(deps): bump org.apache:apache from 31 to 32 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/32 +* chore(deps): bump io.netty:netty-common from 4.1.108.Final to 4.1.109.Final by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/31 +* chore(deps): bump commons-cli:commons-cli from 1.6.0 to 1.7.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/33 +* chore(deps): bump ch.qos.logback:logback-core from 1.5.5 to 1.5.6 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/34 +* chore(deps): bump ch.qos.logback:logback-classic from 1.5.5 to 1.5.6 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/35 +* chore(deps): bump org.bouncycastle:bcprov-jdk18on from 1.78 to 1.78.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/40 +* chore(deps): bump org.bouncycastle:bcmail-jdk18on from 1.78 to 1.78.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/39 +* chore(deps): bump org.springframework.boot:spring-boot-maven-plugin from 3.2.4 to 3.2.5 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/38 +* chore(deps): bump org.bouncycastle:bcpkix-jdk18on from 1.78 to 1.78.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/37 +* chore(deps): bump org.springframework.boot:spring-boot-dependencies from 3.2.4 to 3.2.5 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/36 +* chore(deps): bump iotdb.version from 1.3.0 to 1.3.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/43 +* chore(deps): bump org.apache.maven.plugins:maven-jar-plugin from 3.4.0 to 3.4.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/44 +* chore(deps): bump commons-codec:commons-codec from 1.16.1 to 1.17.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/46 +* chore(deps): bump com.google.errorprone:error_prone_annotations from 2.26.1 to 2.27.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/45 +* chore(deps): bump org.checkerframework:checker-qual from 3.42.0 to 3.43.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/48 +* chore(deps): bump com.google.errorprone:error_prone_annotations from 2.27.0 to 2.27.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/47 +* chore(deps-dev): bump com.fasterxml.jackson.dataformat:jackson-dataformat-yaml from 2.17.0 to 2.17.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/51 +* chore(deps): bump com.fasterxml.jackson.core:jackson-annotations from 2.17.0 to 2.17.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/50 +* chore(deps): bump com.fasterxml.jackson.core:jackson-databind from 2.17.0 to 2.17.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/49 +* chore(deps): bump calcite-core.version from 1.36.0 to 1.37.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/54 +* chore(deps): bump nifi.version from 1.25.0 to 1.26.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/53 +* chore(deps): bump kotlin.version from 1.9.23 to 1.9.24 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/57 +* chore(deps): bump org.eclipse.jetty:jetty-util from 12.0.8 to 12.0.9 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/56 +* chore(deps): bump github.com/rs/zerolog from 1.32.0 to 1.33.0 in /plc4go by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/66 +* chore(deps): bump github.com/schollz/progressbar/v3 from 3.14.2 to 3.14.3 in /plc4go by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/65 +* chore(deps): bump io.nats:jnats from 2.17.6 to 2.18.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/64 +* chore(deps): bump com.influxdb:influxdb-client-java from 7.0.0 to 7.1.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/63 +* chore(deps): bump org.eclipse.milo:sdk-server from 0.6.12 to 0.6.13 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/62 +* chore(deps): bump com.google.code.gson:gson from 2.10.1 to 2.11.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/61 +* chore(deps): bump org.eclipse.milo:sdk-core from 0.6.12 to 0.6.13 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/60 +* chore(deps): bump github.com/fatih/color from 1.16.0 to 1.17.0 in /plc4go by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/59 +* chore(deps): bump org.eclipse.milo:stack-server from 0.6.12 to 0.6.13 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/71 +* chore(deps): bump io.netty:netty-buffer from 4.1.109.Final to 4.1.110.Final by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/70 +* chore(deps): bump io.netty:netty-common from 4.1.109.Final to 4.1.110.Final by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/69 +* chore(deps): bump kotlin.version from 1.9.24 to 2.0.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/68 +* chore(deps): bump org.eclipse.milo:dictionary-manager from 0.6.12 to 0.6.13 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/67 +* chore(deps): bump org.checkerframework:checker-qual from 3.43.0 to 3.44.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/80 +* chore(deps): bump github.com/schollz/progressbar/v3 from 3.14.3 to 3.14.4 in /plc4go by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/79 +* chore(deps): bump github.com/spf13/viper from 1.18.2 to 1.19.0 in /plc4go by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/77 +* chore(deps): bump commons-cli:commons-cli from 1.7.0 to 1.8.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/75 +* chore(deps): bump org.assertj:assertj-core from 3.25.3 to 3.26.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/73 +* chore(deps): bump org.eclipse.milo:stack-core from 0.6.12 to 0.6.13 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/72 +* chore(deps): bump com.google.errorprone:error_prone_annotations from 2.27.1 to 2.28.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/84 +* chore(deps): bump io.nats:jnats from 2.18.1 to 2.19.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/83 +* chore(deps): bump org.eclipse.jetty:jetty-util from 12.0.9 to 12.0.10 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/82 +* chore(deps): bump org.apache.commons:commons-configuration2 from 2.10.1 to 2.11.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/81 +* chore(deps): bump io.netty:netty-common from 4.1.110.Final to 4.1.111.Final by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/87 +* chore(deps): bump io.netty:netty-buffer from 4.1.110.Final to 4.1.111.Final by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/86 +* chore(deps): bump io.netty:netty-transport from 4.1.109.Final to 4.1.111.Final by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/85 +* chore(deps): bump github.com/spf13/cobra from 1.8.0 to 1.8.1 in /plc4go by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/88 +* chore(deps): bump io.jsonwebtoken:jjwt-api from 0.12.5 to 0.12.6 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/89 +* chore(deps): bump io.nats:jnats from 2.19.0 to 2.19.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/90 +* chore(deps): bump org.junit:junit-bom from 5.10.2 to 5.10.3 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/94 +* chore(deps-dev): bump org.junit.jupiter:junit-jupiter-engine from 5.10.2 to 5.10.3 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/92 +* chore(deps): bump org.junit.jupiter:junit-jupiter-api from 5.10.2 to 5.10.3 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/93 +* chore(deps): bump kafka.version from 3.7.0 to 3.7.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/95 +* chore(deps): bump iotdb.version from 1.3.1 to 1.3.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/96 +* chore(deps): bump org.checkerframework:checker-qual from 3.44.0 to 3.45.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/97 +* chore(deps): bump org.eclipse.jetty:jetty-util from 12.0.10 to 12.0.11 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/98 +* chore(deps): bump com.fasterxml.jackson.core:jackson-annotations from 2.17.1 to 2.17.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/102 +* chore(deps-dev): bump com.fasterxml.jackson.dataformat:jackson-dataformat-yaml from 2.17.1 to 2.17.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/101 +* chore(deps): bump com.fasterxml.jackson.core:jackson-databind from 2.17.1 to 2.17.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/100 +* chore(deps): bump nifi.version from 1.26.0 to 1.27.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/99 +* chore(deps): bump com.google.errorprone:error_prone_annotations from 2.28.0 to 2.29.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/107 +* chore(deps): bump commons-codec:commons-codec from 1.17.0 to 1.17.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/105 +* chore(deps): bump org.assertj:assertj-core from 3.26.0 to 3.26.3 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/103 +* chore(deps): bump io.netty:netty-buffer from 4.1.111.Final to 4.1.112.Final by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/110 +* chore(deps): bump org.apache.commons:commons-lang3 from 3.14.0 to 3.15.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/109 +* chore(deps): bump com.google.errorprone:error_prone_annotations from 2.29.1 to 2.29.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/108 +* chore(deps): bump io.netty:netty-common from 4.1.111.Final to 4.1.112.Final by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/112 +* chore(deps): bump io.netty:netty-transport from 4.1.111.Final to 4.1.112.Final by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/111 +* chore(deps): bump github.com/schollz/progressbar/v3 from 3.14.4 to 3.14.5 in /plc4go by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/114 +* chore(deps): bump io.nats:jnats from 2.19.1 to 2.20.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/116 +* chore(deps): bump org.eclipse.jetty:jetty-util from 12.0.11 to 12.0.12 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/115 +* chore(deps): bump kafka.version from 3.7.1 to 3.8.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/113 +* chore(deps): bump org.eclipse.milo:stack-core from 0.6.13 to 0.6.14 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/118 +* chore(deps): bump org.eclipse.milo:dictionary-manager from 0.6.13 to 0.6.14 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/119 +* chore(deps): bump github.com/schollz/progressbar/v3 from 3.14.5 to 3.14.6 in /plc4go by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/122 +* chore(deps): bump org.eclipse.milo:sdk-core from 0.6.13 to 0.6.14 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/121 +* chore(deps): bump org.cyclonedx:cyclonedx-maven-plugin from 2.8.0 to 2.8.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/120 +* fix(plc4j): Fix compile errors after updates to plc4j. by @splatch in https://github.com/apache/plc4x-extras/pull/126 +* chore(deps): bump com.google.errorprone:error_prone_annotations from 2.29.2 to 2.31.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/130 +* chore(deps): bump com.google.protobuf:protobuf-java from 3.25.3 to 4.28.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/129 +* chore(deps): bump ch.qos.logback:logback-core from 1.5.6 to 1.5.7 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/131 +* chore(deps): bump kotlin.version from 2.0.0 to 2.0.20 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/132 +* chore(deps): bump org.junit:junit-bom from 5.10.3 to 5.11.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/134 +* chore(deps-dev): bump org.junit.jupiter:junit-jupiter-engine from 5.10.3 to 5.11.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/133 +* chore(deps): bump io.nats:jnats from 2.20.0 to 2.20.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/136 +* chore(deps): bump org.apache.commons:commons-lang3 from 3.15.0 to 3.17.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/135 +* chore(deps): bump ch.qos.logback:logback-classic from 1.5.6 to 1.5.7 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/137 +* chore(deps): bump org.eclipse.milo:sdk-server from 0.6.13 to 0.6.14 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/138 +* chore(deps): bump github.com/schollz/progressbar/v3 from 3.14.6 to 3.15.0 in /plc4go by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/141 +* chore(deps): bump com.influxdb:influxdb-client-java from 7.1.0 to 7.2.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/139 +* chore(deps): bump org.junit.jupiter:junit-jupiter-api from 5.10.3 to 5.11.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/140 +* chore(deps): bump org.apache.avro:avro from 1.11.3 to 1.12.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/123 +* chore(deps): bump com.google.protobuf:protobuf-java from 4.28.0 to 4.28.2 in /plc4j/integrations/apache-calcite by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/142 +* chore(deps): bump io.netty:netty-buffer from 4.1.112.Final to 4.1.113.Final by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/145 +* chore(deps): bump slf4j.version from 2.0.13 to 2.0.16 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/144 +* chore(deps): bump org.eclipse.milo:stack-server from 0.6.13 to 0.6.14 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/143 +* chore(deps): bump com.google.errorprone:error_prone_annotations from 2.31.0 to 2.32.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/149 +* chore(deps): bump github.com/schollz/progressbar/v3 from 3.15.0 to 3.16.0 in /plc4go by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/146 +* chore(deps): bump io.netty:netty-transport from 4.1.112.Final to 4.1.113.Final by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/147 +* chore(deps): bump ch.qos.logback:logback-core from 1.5.7 to 1.5.8 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/148 +* chore(deps): bump ch.qos.logback:logback-classic from 1.5.7 to 1.5.8 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/154 +* chore(deps): bump commons-io:commons-io from 2.16.1 to 2.17.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/153 +* chore(deps): bump com.gradle:common-custom-user-data-maven-extension from 2 to 2.0.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/152 +* chore(deps-dev): bump org.junit.jupiter:junit-jupiter-engine from 5.11.0 to 5.11.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/157 +* chore(deps): bump org.junit:junit-bom from 5.11.0 to 5.11.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/156 +* chore(deps): bump org.eclipse.jetty:jetty-util from 12.0.12 to 12.0.13 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/155 +* chore(deps): bump joda-time:joda-time from 2.12.7 to 2.13.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/160 +* chore(deps): bump org.junit.jupiter:junit-jupiter-api from 5.11.0 to 5.11.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/159 +* chore(deps): bump com.fasterxml.jackson.core:jackson-annotations from 2.17.2 to 2.18.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/158 +* chore(deps): bump com.fasterxml.jackson.core:jackson-databind from 2.17.2 to 2.18.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/163 +* chore(deps): bump org.cyclonedx:cyclonedx-maven-plugin from 2.8.1 to 2.8.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/162 +* chore(deps): bump com.google.errorprone:error_prone_annotations from 2.32.0 to 2.33.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/161 +* chore(deps): bump org.apache:apache from 32 to 33 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/104 +* chore(deps): bump github.com/gopacket/gopacket from 1.2.0 to 1.3.0 in /plc4go by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/127 +* chore(deps): bump github.com/schollz/progressbar/v3 from 3.16.0 to 3.16.1 in /plc4go by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/168 +* chore(deps): bump commons-cli:commons-cli from 1.8.0 to 1.9.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/164 +* chore(deps): bump io.netty:netty-common from 4.1.112.Final to 4.1.114.Final by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/165 +* chore(deps): bump org.eclipse.jetty:jetty-util from 12.0.13 to 12.0.14 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/166 +* chore(deps-dev): bump com.fasterxml.jackson.dataformat:jackson-dataformat-yaml from 2.17.2 to 2.18.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/167 +* chore(deps): bump org.checkerframework:checker-qual from 3.45.0 to 3.48.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/169 +* chore(deps-dev): bump org.junit.jupiter:junit-jupiter-engine from 5.11.1 to 5.11.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/170 +* chore(deps): bump org.junit:junit-bom from 5.11.1 to 5.11.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/171 +* chore(deps): bump io.netty:netty-transport from 4.1.113.Final to 4.1.114.Final by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/172 +* chore(deps): bump org.junit.jupiter:junit-jupiter-api from 5.11.1 to 5.11.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/174 +* chore(deps): bump io.netty:netty-buffer from 4.1.113.Final to 4.1.114.Final by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/173 +* chore(deps): bump ch.qos.logback:logback-core from 1.5.8 to 1.5.9 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/175 +* chore(deps): bump org.cyclonedx:cyclonedx-maven-plugin from 2.8.2 to 2.9.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/177 +* chore(deps): bump ch.qos.logback:logback-classic from 1.5.8 to 1.5.9 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/176 +* chore(deps): bump ch.qos.logback:logback-classic from 1.5.9 to 1.5.10 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/183 +* chore(deps): bump ch.qos.logback:logback-core from 1.5.9 to 1.5.10 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/181 +* chore(deps): bump kotlin.version from 2.0.20 to 2.0.21 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/178 +* chore(deps): bump org.checkerframework:checker-qual from 3.48.0 to 3.48.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/182 +* chore(deps): bump io.vavr:vavr from 0.10.4 to 0.10.5 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/185 +* chore(deps): bump calcite-core.version from 1.37.0 to 1.38.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/184 +* chore(deps): bump ch.qos.logback:logback-core from 1.5.10 to 1.5.11 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/186 +* chore(deps): bump ch.qos.logback:logback-classic from 1.5.10 to 1.5.11 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/187 +* chore(deps): bump com.google.errorprone:error_prone_annotations from 2.33.0 to 2.34.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/188 +* fix nifi schema cache not writing some tags by @LaManoServida in https://github.com/apache/plc4x-extras/pull/179 +* chore(deps): bump org.junit:junit-bom from 5.11.2 to 5.11.3 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/191 +* chore(deps): bump org.junit.jupiter:junit-jupiter-api from 5.11.2 to 5.11.3 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/189 +* chore(deps-dev): bump org.junit.jupiter:junit-jupiter-engine from 5.11.2 to 5.11.3 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/190 +* chore(deps): bump com.google.protobuf:protobuf-java from 4.28.2 to 4.28.3 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/194 +* chore(deps): bump github.com/fatih/color from 1.17.0 to 1.18.0 in /plc4go by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/193 +* chore(deps): bump ch.qos.logback:logback-core from 1.5.11 to 1.5.12 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/200 +* chore(deps): bump ch.qos.logback:logback-classic from 1.5.11 to 1.5.12 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/199 +* chore(deps): bump com.google.errorprone:error_prone_annotations from 2.34.0 to 2.35.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/198 +* chore(deps): bump github.com/schollz/progressbar/v3 from 3.16.1 to 3.17.0 in /plc4go by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/197 +* chore(deps): bump kafka.version from 3.8.0 to 3.8.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/204 +* chore(deps-dev): bump com.fasterxml.jackson.dataformat:jackson-dataformat-yaml from 2.18.0 to 2.18.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/201 +* chore(deps): bump com.fasterxml.jackson.core:jackson-databind from 2.18.0 to 2.18.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/202 +* chore(deps): bump com.fasterxml.jackson.core:jackson-annotations from 2.18.0 to 2.18.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/203 +* chore(deps): bump org.bouncycastle:bcprov-jdk18on from 1.78.1 to 1.79 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/208 +* chore(deps): bump org.bouncycastle:bcmail-jdk18on from 1.78.1 to 1.79 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/207 +* chore(deps): bump org.bouncycastle:bcpkix-jdk18on from 1.78.1 to 1.79 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/206 +* chore(deps): bump nifi.version from 1.27.0 to 1.28.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/205 +* chore(deps): bump io.nats:jnats from 2.20.2 to 2.20.4 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/210 +* chore(deps): bump org.checkerframework:checker-qual from 3.48.1 to 3.48.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/209 +* chore(deps): bump org.eclipse.jetty:jetty-util from 12.0.14 to 12.0.15 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/211 +* chore(deps): bump kafka.version from 3.8.1 to 3.9.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/212 +* chore(deps): bump github.com/schollz/progressbar/v3 from 3.17.0 to 3.17.1 in /plc4go by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/213 +* chore(deps): bump github.com/gopacket/gopacket from 1.3.0 to 1.3.1 in /plc4go by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/217 +* chore(deps): bump io.netty:netty-transport from 4.1.114.Final to 4.1.115.Final by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/215 +* chore(deps): bump io.netty:netty-common from 4.1.114.Final to 4.1.115.Final by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/214 +* chore(deps): bump com.google.errorprone:error_prone_annotations from 2.35.1 to 2.36.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/218 +* chore(deps): bump commons-io:commons-io from 2.17.0 to 2.18.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/219 +* chore(deps): bump nifi.version from 1.28.0 to 1.28.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/220 +* chore(deps): bump github.com/stretchr/testify from 1.9.0 to 1.10.0 in /plc4go by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/221 +* chore(deps): bump com.google.protobuf:protobuf-java from 4.28.3 to 4.29.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/225 +* chore(deps): bump com.fasterxml.jackson.core:jackson-databind from 2.18.1 to 2.18.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/224 +* chore(deps): bump kotlin.version from 2.0.21 to 2.1.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/223 +* chore(deps): bump com.fasterxml.jackson.core:jackson-annotations from 2.18.1 to 2.18.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/222 +* fix address dynamic properties not deleting in nifi processors by @LaManoServida in https://github.com/apache/plc4x-extras/pull/180 +* Remove avro internal schema by @QuanticPony in https://github.com/apache/plc4x-extras/pull/196 +* Revert "Remove avro internal schema" by @ottobackwards in https://github.com/apache/plc4x-extras/pull/226 +* chore(deps): bump org.cyclonedx:cyclonedx-maven-plugin from 2.9.0 to 2.9.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/227 +* chore(deps-dev): bump com.fasterxml.jackson.dataformat:jackson-dataformat-yaml from 2.18.1 to 2.18.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/228 +* Fix remove internal avro schema by @QuanticPony in https://github.com/apache/plc4x-extras/pull/229 +* chore(deps): bump org.checkerframework:checker-qual from 3.48.2 to 3.48.3 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/230 +* chore(deps): bump org.eclipse.milo:stack-server from 0.6.14 to 0.6.15 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/231 +* chore(deps): bump com.hivemq:hivemq-mqtt-client from 1.3.3 to 1.3.4 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/232 +* chore(deps): bump org.eclipse.milo:sdk-core from 0.6.14 to 0.6.15 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/233 +* chore(deps): bump org.eclipse.milo:stack-core from 0.6.14 to 0.6.15 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/234 +* chore(deps): bump com.google.protobuf:protobuf-java from 4.29.0 to 4.29.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/235 +* chore(deps): bump org.eclipse.milo:dictionary-manager from 0.6.14 to 0.6.15 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/236 +* chore(deps): bump org.eclipse.milo:sdk-server from 0.6.14 to 0.6.15 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/237 +* chore(deps): bump io.nats:jnats from 2.20.4 to 2.20.5 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/238 +* chore(deps): bump org.eclipse.jetty:jetty-util from 12.0.15 to 12.0.16 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/239 +* chore(deps-dev): bump org.junit.jupiter:junit-jupiter-engine from 5.11.3 to 5.11.4 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/242 +* chore(deps): bump org.junit:junit-bom from 5.11.3 to 5.11.4 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/241 +* chore(deps): bump org.junit.jupiter:junit-jupiter-api from 5.11.3 to 5.11.4 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/240 +* chore(deps): bump ch.qos.logback:logback-classic from 1.5.12 to 1.5.14 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/248 +* chore(deps): bump com.google.protobuf:protobuf-java from 4.29.1 to 4.29.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/246 +* chore(deps): bump io.netty:netty-transport from 4.1.115.Final to 4.1.116.Final by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/243 +* chore(deps): bump ch.qos.logback:logback-core from 1.5.12 to 1.5.14 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/247 +* chore(deps): bump org.assertj:assertj-core from 3.26.3 to 3.27.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/252 +* chore(deps): bump ch.qos.logback:logback-core from 1.5.14 to 1.5.15 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/251 +* chore(deps): bump ch.qos.logback:logback-classic from 1.5.14 to 1.5.15 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/250 +* chore(deps): bump io.netty:netty-buffer from 4.1.114.Final to 4.1.116.Final by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/249 +* chore(deps): bump io.netty:netty-common from 4.1.115.Final to 4.1.116.Final by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/253 +* chore(deps): bump org.assertj:assertj-core from 3.27.0 to 3.27.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/258 +* chore(deps): bump ch.qos.logback:logback-core from 1.5.15 to 1.5.16 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/257 +* chore(deps): bump ch.qos.logback:logback-classic from 1.5.15 to 1.5.16 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/256 +* chore(deps): bump org.checkerframework:checker-qual from 3.48.3 to 3.48.4 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/255 +* chore(deps): bump commons-codec:commons-codec from 1.17.1 to 1.17.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/259 +* chore(deps): bump com.google.protobuf:protobuf-java from 4.29.2 to 4.29.3 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/261 +* chore(deps): bump github.com/gdamore/tcell/v2 from 2.7.4 to 2.8.0 in /plc4go by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/263 +* chore(deps): bump github.com/schollz/progressbar/v3 from 3.17.1 to 3.18.0 in /plc4go by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/262 +* chore(deps): bump github.com/gdamore/tcell/v2 from 2.8.0 to 2.8.1 in /plc4go by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/265 +* Publish build scans to develocity.apache.org by @clayburn in https://github.com/apache/plc4x-extras/pull/266 +* chore(deps): bump io.netty:netty-common from 4.1.116.Final to 4.1.117.Final by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/270 +* chore(deps): bump org.bouncycastle:bcpkix-jdk18on from 1.79 to 1.80 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/269 +* chore(deps): bump org.bouncycastle:bcmail-jdk18on from 1.79 to 1.80 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/267 +* chore(deps): bump io.netty:netty-buffer from 4.1.116.Final to 4.1.117.Final by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/268 +* chore(deps): bump io.swagger:swagger-annotations from 1.6.14 to 1.6.15 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/271 +* chore(deps): bump io.netty:netty-transport from 4.1.116.Final to 4.1.117.Final by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/272 +* chore(deps): bump org.bouncycastle:bcprov-jdk18on from 1.79 to 1.80 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/273 +* chore(deps): bump com.gradle:develocity-maven-extension from 1.22.2 to 1.23 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/274 +* chore(deps): bump org.assertj:assertj-core from 3.27.2 to 3.27.3 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/275 +* chore(deps): bump com.gradle:develocity-maven-extension from 1.23 to 1.23.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/276 +* chore(deps): bump commons-codec:commons-codec from 1.17.2 to 1.18.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/278 +* chore(deps): bump com.google.code.gson:gson from 2.11.0 to 2.12.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/279 +* chore(deps): bump com.google.code.gson:gson from 2.12.0 to 2.12.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/280 +* chore(deps): bump io.vavr:vavr from 0.10.5 to 0.10.6 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/281 +* chore(deps): bump org.checkerframework:checker-qual from 3.48.4 to 3.49.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/283 +* chore(deps): bump joda-time:joda-time from 2.13.0 to 2.13.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/282 +* chore(deps): bump io.netty:netty-buffer from 4.1.117.Final to 4.1.118.Final by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/286 +* chore(deps): bump io.netty:netty-transport from 4.1.117.Final to 4.1.118.Final by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/285 +* chore(deps): bump io.netty:netty-common from 4.1.117.Final to 4.1.118.Final by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/284 +* chore(deps): bump com.hivemq:hivemq-mqtt-client from 1.3.4 to 1.3.5 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/287 +* chore(deps): bump com.gradle:develocity-maven-extension from 1.23.1 to 1.23.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/292 +* chore(deps): bump ch.qos.logback:logback-core from 1.5.16 to 1.5.17 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/293 +* chore(deps): bump dorny/test-reporter from 1 to 2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/295 +* chore(deps): bump org.eclipse.jetty:jetty-util from 12.0.16 to 12.0.17 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/294 +* chore(deps): bump com.google.protobuf:protobuf-java from 4.29.3 to 4.30.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/296 +* chore(deps): bump org.junit:junit-bom from 5.11.4 to 5.12.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/291 +* chore(deps-dev): bump org.junit.jupiter:junit-jupiter-engine from 5.11.4 to 5.12.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/290 +* chore(deps): bump org.junit.jupiter:junit-jupiter-api from 5.11.4 to 5.12.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/289 +* chore(deps): bump github.com/spf13/cobra from 1.8.1 to 1.9.1 in /plc4go by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/288 +* chore(deps-dev): bump com.fasterxml.jackson.dataformat:jackson-dataformat-yaml from 2.18.2 to 2.18.3 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/300 +* chore(deps-dev): bump org.junit.jupiter:junit-jupiter-engine from 5.12.0 to 5.12.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/299 +* chore(deps): bump calcite-core.version from 1.38.0 to 1.39.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/298 +* chore(deps): bump ch.qos.logback:logback-classic from 1.5.16 to 1.5.17 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/297 +* chore(deps): bump github.com/spf13/viper from 1.19.0 to 1.20.0 in /plc4go by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/301 +* chore(deps): bump com.fasterxml.jackson.core:jackson-databind from 2.18.2 to 2.18.3 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/305 +* chore(deps): bump org.junit.jupiter:junit-jupiter-api from 5.12.0 to 5.12.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/304 +* chore(deps): bump io.nats:jnats from 2.20.5 to 2.20.6 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/303 +* chore(deps): bump org.eclipse.jetty:jetty-util from 12.0.17 to 12.0.18 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/302 +* chore(deps): bump org.checkerframework:checker-qual from 3.49.0 to 3.49.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/309 +* chore(deps): bump org.apache:apache from 33 to 34 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/308 +* chore(deps): bump ch.qos.logback:logback-classic from 1.5.17 to 1.5.18 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/307 +* chore(deps): bump com.google.errorprone:error_prone_annotations from 2.36.0 to 2.37.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/312 +* chore(deps): bump ch.qos.logback:logback-core from 1.5.17 to 1.5.18 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/310 +* chore(deps): bump slf4j.version from 2.0.16 to 2.0.17 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/306 +* chore(deps): bump github.com/rs/zerolog from 1.33.0 to 1.34.0 in /plc4go by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/316 +* chore(deps): bump com.igormaznitsa:gosdk-wrapper-maven-plugin from 1.0.2 to 1.0.3 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/313 +* chore(deps): bump com.fasterxml.jackson.core:jackson-annotations from 2.18.2 to 2.18.3 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/315 +* chore(deps): bump org.junit:junit-bom from 5.12.0 to 5.12.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/314 +* chore(deps): bump io.nats:jnats from 2.20.6 to 2.21.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/320 +* chore(deps): bump com.google.protobuf:protobuf-java from 4.30.1 to 4.30.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/319 +* chore(deps): bump github.com/spf13/viper from 1.20.0 to 1.20.1 in /plc4go by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/318 +* chore(deps): bump joda-time:joda-time from 2.13.1 to 2.14.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/322 +* chore(deps): bump com.igormaznitsa:gosdk-wrapper-maven-plugin from 1.0.3 to 1.0.4 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/321 +* chore(deps): bump org.checkerframework:checker-qual from 3.49.1 to 3.49.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/323 +* chore(deps): bump io.netty:netty-common from 4.1.119.Final to 4.2.0.Final by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/326 +* Revert "chore(deps): bump io.netty:netty-common from 4.1.119.Final to 4.2.0.Final" by @sruehl in https://github.com/apache/plc4x-extras/pull/328 +* chore(deps): bump org.eclipse.milo:dictionary-manager from 0.6.15 to 0.6.16 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/343 +* chore(deps): bump commons-io:commons-io from 2.18.0 to 2.19.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/342 +* chore(deps): bump com.fasterxml.jackson.core:jackson-databind from 2.18.3 to 2.19.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/341 +* chore(deps): bump com.gradle:common-custom-user-data-maven-extension from 2.0.1 to 2.0.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/346 +* chore(deps): bump org.eclipse.milo:sdk-server from 0.6.15 to 0.6.16 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/345 +* chore(deps): bump com.google.code.gson:gson from 2.12.1 to 2.13.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/344 +* chore(deps): bump org.apache.commons:commons-collections4 from 4.4 to 4.5.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/349 +* chore(deps): bump org.apache.commons:commons-configuration2 from 2.11.0 to 2.12.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/348 +* chore(deps): bump org.eclipse.milo:stack-server from 0.6.15 to 0.6.16 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/347 +* chore(deps): bump org.eclipse.milo:sdk-core from 0.6.15 to 0.6.16 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/351 +* chore(deps): bump org.junit:junit-bom from 5.12.1 to 5.12.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/352 +* chore(deps): bump org.checkerframework:checker-qual from 3.49.2 to 3.49.3 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/353 +* chore(deps): bump org.eclipse.jetty:jetty-util from 12.0.18 to 12.0.21 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/356 +* chore(deps): bump com.fasterxml.jackson.core:jackson-annotations from 2.18.3 to 2.19.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/355 +* chore(deps): bump org.eclipse.milo:stack-core from 0.6.15 to 0.6.16 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/354 +* chore(deps): bump org.junit.jupiter:junit-jupiter-api from 5.12.1 to 5.12.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/360 +* chore(deps): bump kotlin.version from 2.1.0 to 2.1.21 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/359 +* chore(deps): bump com.google.errorprone:error_prone_annotations from 2.37.0 to 2.38.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/358 +* chore(deps-dev): bump org.junit.jupiter:junit-jupiter-engine from 5.12.1 to 5.12.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/357 +* chore(deps): bump io.swagger:swagger-annotations from 1.6.15 to 1.6.16 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/364 +* chore(deps-dev): bump com.fasterxml.jackson.dataformat:jackson-dataformat-yaml from 2.18.3 to 2.19.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/363 +* chore(deps): bump com.google.protobuf:protobuf-java from 4.30.2 to 4.31.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/362 +* chore(deps): bump com.gradle:develocity-maven-extension from 1.23.2 to 2.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/361 +* chore(deps): bump io.nats:jnats from 2.21.0 to 2.21.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/365 +* NiFi 1 and 2 compatibility by @QuanticPony in https://github.com/apache/plc4x-extras/pull/336 +* chore(deps): bump org.junit:junit-bom from 5.12.1 to 5.12.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/366 +* chore(deps): bump com.influxdb:influxdb-client-java from 7.2.0 to 7.3.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/367 +* Deprecate the non-record based processors by @ottobackwards in https://github.com/apache/plc4x-extras/pull/333 +* chore(deps): bump org.junit:junit-bom from 5.12.2 to 5.13.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/371 +* chore(deps): bump com.gradle:develocity-maven-extension from 2.0 to 2.0.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/370 +* chore(deps): bump calcite-core.version from 1.39.0 to 1.40.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/368 +* chore(deps): bump com.google.protobuf:protobuf-java from 4.31.0 to 4.31.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/369 +* Feat/nifi integration improve logging by @QuanticPony in https://github.com/apache/plc4x-extras/pull/334 +* chore(deps): bump org.junit.jupiter:junit-jupiter-api from 5.12.2 to 5.13.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/375 +* chore(deps): bump io.nats:jnats from 2.21.1 to 2.21.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/374 +* chore(deps-dev): bump org.junit.jupiter:junit-jupiter-engine from 5.12.2 to 5.13.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/373 +* chore(deps): bump org.checkerframework:checker-qual from 3.49.3 to 3.49.4 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/372 +* chore(deps): bump com.hivemq:hivemq-mqtt-client from 1.3.5 to 1.3.6 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/377 +* chore(deps): bump org.eclipse.jetty:jetty-util from 12.0.21 to 12.0.22 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/376 +* chore(deps): bump org.bouncycastle:bcmail-jdk18on from 1.80 to 1.81 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/378 +* chore(deps): bump org.bouncycastle:bcprov-jdk18on from 1.80 to 1.81 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/379 +* chore(deps): bump org.bouncycastle:bcpkix-jdk18on from 1.80 to 1.81 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/380 +* chore(deps-dev): bump org.junit.jupiter:junit-jupiter-engine from 5.13.0 to 5.13.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/387 +* chore(deps): bump org.junit:junit-bom from 5.13.0 to 5.13.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/386 +* chore(deps): bump com.gradle:common-custom-user-data-maven-extension from 2.0.2 to 2.0.3 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/385 +* chore(deps): bump org.junit.jupiter:junit-jupiter-api from 5.13.0 to 5.13.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/384 +* chore(deps): bump com.hivemq:hivemq-mqtt-client from 1.3.6 to 1.3.7 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/388 +* chore(deps): bump com.fasterxml.jackson.core:jackson-annotations from 2.19.0 to 2.19.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/392 +* chore(deps-dev): bump com.fasterxml.jackson.dataformat:jackson-dataformat-yaml from 2.19.0 to 2.19.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/391 +* chore(deps): bump com.fasterxml.jackson.core:jackson-databind from 2.19.0 to 2.19.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/390 +* chore(deps): bump kotlin.version from 2.1.21 to 2.2.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/393 +* chore(deps): bump org.junit:junit-bom from 5.13.1 to 5.13.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/396 +* chore(deps): bump org.junit.jupiter:junit-jupiter-api from 5.13.1 to 5.13.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/395 +* chore(deps): bump com.google.errorprone:error_prone_annotations from 2.38.0 to 2.39.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/394 +* chore(deps-dev): bump org.junit.jupiter:junit-jupiter-engine from 5.13.1 to 5.13.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/397 +* bump io.nats:jnats from 2.21.2 to 2.21.3 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/398 +* chore(deps): bump com.igormaznitsa:gosdk-wrapper-maven-plugin from 1.0.4 to 1.0.5 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/400 +* chore(deps): bump org.checkerframework:checker-qual from 3.49.4 to 3.49.5 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/399 +* chore(deps): bump org.eclipse.jetty:jetty-util from 12.0.22 to 12.0.23 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/401 +* chore(deps-dev): bump org.junit.jupiter:junit-jupiter-engine from 5.13.2 to 5.13.3 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/404 +* chore(deps): bump org.junit.jupiter:junit-jupiter-api from 5.13.2 to 5.13.3 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/403 +* chore(deps): bump org.junit:junit-bom from 5.13.2 to 5.13.3 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/402 +* chore(deps): bump org.apache:apache from 34 to 35 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/389 +* chore(deps): bump com.google.errorprone:error_prone_annotations from 2.39.0 to 2.40.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/407 +* chore(deps): bump io.nats:jnats from 2.21.3 to 2.21.4 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/406 +* chore(deps): bump io.netty:netty-common from 4.1.119.Final to 4.2.3.Final by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/405 +* chore(deps): bump com.gradle:develocity-maven-extension from 2.0.1 to 2.1 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/410 +* chore(deps): bump org.apache.commons:commons-lang3 from 3.17.0 to 3.18.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/409 +* chore(deps): bump com.fasterxml.jackson.core:jackson-annotations from 2.19.1 to 2.19.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/415 +* chore(deps-dev): bump com.fasterxml.jackson.dataformat:jackson-dataformat-yaml from 2.19.1 to 2.19.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/414 +* chore(deps): bump io.vavr:vavr from 0.10.6 to 0.10.7 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/413 +* chore(deps): bump com.fasterxml.jackson.core:jackson-databind from 2.19.1 to 2.19.2 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/412 +* chore(deps): bump org.junit.jupiter:junit-jupiter-api from 5.13.3 to 5.13.4 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/416 +* chore(deps): bump org.junit:junit-bom from 5.13.3 to 5.13.4 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/420 +* chore(deps-dev): bump org.junit.jupiter:junit-jupiter-engine from 5.13.3 to 5.13.4 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/419 +* chore(deps): bump commons-codec:commons-codec from 1.18.0 to 1.19.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/418 +* chore(deps): bump commons-io:commons-io from 2.19.0 to 2.20.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/421 +* chore(deps): bump com.google.errorprone:error_prone_annotations from 2.40.0 to 2.41.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/422 +* NiFi Integration - Fix array reading by @QuanticPony in https://github.com/apache/plc4x-extras/pull/423 +* chore(deps): bump github.com/gopacket/gopacket from 1.3.1 to 1.4.0 in /plc4go by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/425 +* chore(deps): bump commons-cli:commons-cli from 1.9.0 to 1.10.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/424 +* chore(deps): bump org.apache.plc4x:plc4j-driver-modbus from 0.13.0-SNAPSHOT to 0.13.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/429 +* chore(deps): bump org.apache.plc4x:plc4j-driver-s7 from 0.13.0-SNAPSHOT to 0.13.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/427 +* chore(deps): bump org.apache.plc4x:plc4j-transport-raw-socket from 0.13.0-SNAPSHOT to 0.13.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/428 +* chore(deps): bump org.apache.plc4x:plc4j-transport-tcp from 0.13.0-SNAPSHOT to 0.13.0 by @dependabot[bot] in https://github.com/apache/plc4x-extras/pull/426 + +## New Contributors +* @splatch made their first contribution in https://github.com/apache/plc4x-extras/pull/126 +* @LaManoServida made their first contribution in https://github.com/apache/plc4x-extras/pull/179 +* @QuanticPony made their first contribution in https://github.com/apache/plc4x-extras/pull/196 +* @ottobackwards made their first contribution in https://github.com/apache/plc4x-extras/pull/226 +* @clayburn made their first contribution in https://github.com/apache/plc4x-extras/pull/266 +* @sruehl made their first contribution in https://github.com/apache/plc4x-extras/pull/328 + +**Full Changelog**: https://github.com/apache/plc4x-extras/commits/relt/0.13 + +### Feature + +- plc4j/nifi: + - improve logging (#334) ([0ddb3da](https://github.com/apache/plc4x-extras/commit/0ddb3daa7645420ef949d42e67c2d09cc0a00fda)) ([#334](https://github.com/apache/plc4x-extras/pull/334)) + +- nifi: + - NiFi 1 and 2 compatibility (#336) ([ef1b81b](https://github.com/apache/plc4x-extras/commit/ef1b81bc30c8f63ffb6629a2b047530b48bfa83d)) ([#336](https://github.com/apache/plc4x-extras/pull/336)) + +- plc4go: + - update go version and plugin ([12e3aa7](https://github.com/apache/plc4x-extras/commit/12e3aa71e59afb6ab48bf340c27cb08238f04015)) + +- general: + - Git the C build working again. ([97640ce](https://github.com/apache/plc4x-extras/commit/97640cef4edd49252df91ba21b0c167c15eee807)) + +### Bug Fixes + +- tools: + - upgrade to latest tview version ([743dd91](https://github.com/apache/plc4x-extras/commit/743dd91525a82ed6bcf621cca60848d4924d8287)) + +- general: + - Updated the extras repository to latest changes in the main repo. ([134ce6f](https://github.com/apache/plc4x-extras/commit/134ce6f9dd5cb00ffbd09c3a907d39a7d3848f45)) + - Added a transformer to the shade-plugin to merge the META-INF/services resource bundles (Without only one driver was available) ([c007395](https://github.com/apache/plc4x-extras/commit/c00739582de9e459f72bc2415982155c18525114)) + - Fixed an issue with the opcua-server shaded binary, that wouldn't let us start it as some dependencies were signed. ([72f04e3](https://github.com/apache/plc4x-extras/commit/72f04e34fdab8a7444dccea45f2f2a79980f592a)) + - Fixed some of the ADS-related examples. ([437d2d7](https://github.com/apache/plc4x-extras/commit/437d2d79c8a2dac3371191227291ce7a22963a4b)) + - Added back the AMD64 profiles ([7a71406](https://github.com/apache/plc4x-extras/commit/7a714068d0431d3722df6c1ce766c8991999169f)) + - Added back the AMD64 profiles ([3dca103](https://github.com/apache/plc4x-extras/commit/3dca103dca85af3d79efe91fdcf2281b9b6fa983)) + - excluded the Influx example from the build on less than Java 17 as the new influx client requires at least Java 17 ([95aee08](https://github.com/apache/plc4x-extras/commit/95aee089df79f47d38aa9726b74f0b62edbf7e66)) + +- plc4go/plc4xbrowser: + - fix color rendering ([1c9854f](https://github.com/apache/plc4x-extras/commit/1c9854f745f3cf5beac92b3c8b012a7ad31bdbe4)) + +- plc4go/plc4xanalyzer: + - fix color rendering ([6a74afc](https://github.com/apache/plc4x-extras/commit/6a74afc4733eb3bcad74d40e5daba1974da83063)) + +- plc4j: + - fix imports ([83a7ffa](https://github.com/apache/plc4x-extras/commit/83a7ffa5918c9b38e3c0a8c9d1320eb334641181)) + - Fix compile errors after updates to plc4j. ([4774212](https://github.com/apache/plc4x-extras/commit/47742127458e620d9887427970930fae3922fedc)) ([#126](https://github.com/apache/plc4x-extras/pull/126)) + +- plc4go: + - fix module path and update to latest version ([88180f4](https://github.com/apache/plc4x-extras/commit/88180f42f2f9eb76a5102dfab145cb963efa9598)) + - error case detection ([5d139b0](https://github.com/apache/plc4x-extras/commit/5d139b0cd4242e7b546741e2718f2b2e59730504)) + - compile errors ([ae0b52b](https://github.com/apache/plc4x-extras/commit/ae0b52b7b15e5fb2b132e9e04d79f9bc9120e48a)) + - rename package ([b35f70c](https://github.com/apache/plc4x-extras/commit/b35f70c4350ee32d6a53a8a99932a5cf53235b15)) + +- plc4xbrowser: + - browser should now startup properly again ([a4ce0d3](https://github.com/apache/plc4x-extras/commit/a4ce0d3f04247e2303d24fe40a8699c5e932ab77)) + +### Documentation + +- general: + - Added the super-minimal initial LICENSE, NOTICE, README.md and RELEASE_NOTES ([c42b8c0](https://github.com/apache/plc4x-extras/commit/c42b8c051a2ad4cfd83c8ab24859474aca8ae159)) + - Minor updated in the readme. ([8ab5a9a](https://github.com/apache/plc4x-extras/commit/8ab5a9a309932666e2d4176e5e5ee2ae7df2b610)) + - Added my locally used parameters as documentation ([a653f6c](https://github.com/apache/plc4x-extras/commit/a653f6c916f618e3db5fc93e2362e5820d8590ba)) + - Added some more comments on which profiles usually are used on which types of system. ([ce6849f](https://github.com/apache/plc4x-extras/commit/ce6849f3f8bb20e3bd58d0cd3fdd710235b6be44)) + +### Refactor + +- general: + - Added a check to abort with a sensible message, when running the OPC-UA Server in an environment without console. ([b1b5654](https://github.com/apache/plc4x-extras/commit/b1b56546e5ef6278b20def45c2b2e22b262e8b49)) + +### Miscellaneous + +- deps: + - bump org.apache.plc4x:plc4j-transport-tcp (#426) ([e64233a](https://github.com/apache/plc4x-extras/commit/e64233af885a8a1e703467fdc0c4655fafe62260)) ([#426](https://github.com/apache/plc4x-extras/pull/426)) + - bump org.apache.plc4x:plc4j-transport-raw-socket (#428) ([fccae8c](https://github.com/apache/plc4x-extras/commit/fccae8c3e7440ab5a6715108161f6acd22e528df)) ([#428](https://github.com/apache/plc4x-extras/pull/428)) + - bump org.apache.plc4x:plc4j-driver-s7 (#427) ([7511d27](https://github.com/apache/plc4x-extras/commit/7511d279727f0665070696deafcdb1010812dd50)) ([#427](https://github.com/apache/plc4x-extras/pull/427)) + - bump org.apache.plc4x:plc4j-driver-modbus (#429) ([b1a55f6](https://github.com/apache/plc4x-extras/commit/b1a55f62b1acbb478bb905f280e695997018c6c2)) ([#429](https://github.com/apache/plc4x-extras/pull/429)) + - bump commons-cli:commons-cli from 1.9.0 to 1.10.0 (#424) ([3d2bd75](https://github.com/apache/plc4x-extras/commit/3d2bd75796029d580d826bb9108d0f6bc078b7ed)) ([#424](https://github.com/apache/plc4x-extras/pull/424)) + - bump github.com/gopacket/gopacket in /plc4go (#425) ([1435eef](https://github.com/apache/plc4x-extras/commit/1435eefb14ae383807ccf41542fdaef7897766ad)) ([#425](https://github.com/apache/plc4x-extras/pull/425)) + - bump com.google.errorprone:error_prone_annotations (#422) ([869e7f9](https://github.com/apache/plc4x-extras/commit/869e7f91126b2108db9ac1e0d65875e47e54c11e)) ([#422](https://github.com/apache/plc4x-extras/pull/422)) + - bump commons-io:commons-io from 2.19.0 to 2.20.0 (#421) ([1cfafc1](https://github.com/apache/plc4x-extras/commit/1cfafc114d15e853987694f652fc4db3dd9ee9af)) ([#421](https://github.com/apache/plc4x-extras/pull/421)) + - bump commons-codec:commons-codec from 1.18.0 to 1.19.0 (#418) ([b3cbe1f](https://github.com/apache/plc4x-extras/commit/b3cbe1f5e99a6b4f2cc85f807dc2cfe4a769b075)) ([#418](https://github.com/apache/plc4x-extras/pull/418)) + - bump org.junit:junit-bom from 5.13.3 to 5.13.4 (#420) ([436bee5](https://github.com/apache/plc4x-extras/commit/436bee5cc3f3a5ffc79d5a95224c654375cf62c3)) ([#420](https://github.com/apache/plc4x-extras/pull/420)) + - bump org.junit.jupiter:junit-jupiter-api (#416) ([f0c34f2](https://github.com/apache/plc4x-extras/commit/f0c34f2c815fa335875bc11ff992b764e58d58ac)) ([#416](https://github.com/apache/plc4x-extras/pull/416)) + - bump com.fasterxml.jackson.core:jackson-databind (#412) ([5ebee0f](https://github.com/apache/plc4x-extras/commit/5ebee0f4b15d5562b29656b54dcc985a866e6be4)) ([#412](https://github.com/apache/plc4x-extras/pull/412)) + - bump io.vavr:vavr from 0.10.6 to 0.10.7 (#413) ([2e66e4d](https://github.com/apache/plc4x-extras/commit/2e66e4dab58c1a92b99904777e62766894f7579e)) ([#413](https://github.com/apache/plc4x-extras/pull/413)) + - bump com.fasterxml.jackson.core:jackson-annotations (#415) ([6b435c7](https://github.com/apache/plc4x-extras/commit/6b435c795ca8894f231b1a91944e2e0a38e9778a)) ([#415](https://github.com/apache/plc4x-extras/pull/415)) + - bump org.apache.commons:commons-lang3 from 3.17.0 to 3.18.0 (#409) ([4097119](https://github.com/apache/plc4x-extras/commit/4097119ac922591de2f3052642900dcbc31cf84c)) ([#409](https://github.com/apache/plc4x-extras/pull/409)) + - bump com.gradle:develocity-maven-extension (#410) ([54bbb88](https://github.com/apache/plc4x-extras/commit/54bbb88d50027c693ace392846a922d7d50c9ddf)) ([#410](https://github.com/apache/plc4x-extras/pull/410)) + - bump io.netty:netty-common (#405) ([4a1f75c](https://github.com/apache/plc4x-extras/commit/4a1f75c6366d6e81009dfac806d353e2a10cb0e7)) ([#405](https://github.com/apache/plc4x-extras/pull/405)) + - bump io.nats:jnats from 2.21.3 to 2.21.4 (#406) ([0a1bfdc](https://github.com/apache/plc4x-extras/commit/0a1bfdceef8d93fe57b4b6e1d2fbefecc9a4afd7)) ([#406](https://github.com/apache/plc4x-extras/pull/406)) + - bump com.google.errorprone:error_prone_annotations (#407) ([295b274](https://github.com/apache/plc4x-extras/commit/295b27401c5063de0a248dd3adf487378267076f)) ([#407](https://github.com/apache/plc4x-extras/pull/407)) + - bump org.apache:apache from 34 to 35 (#389) ([34d362c](https://github.com/apache/plc4x-extras/commit/34d362c9301fa2a48fd22f3b3afcd2cee9f27071)) ([#389](https://github.com/apache/plc4x-extras/pull/389)) + - bump org.junit:junit-bom from 5.13.2 to 5.13.3 (#402) ([9c6382b](https://github.com/apache/plc4x-extras/commit/9c6382b12f0989f814cf12c74c08f2ff6e69a486)) ([#402](https://github.com/apache/plc4x-extras/pull/402)) + - bump org.junit.jupiter:junit-jupiter-api (#403) ([eb76de7](https://github.com/apache/plc4x-extras/commit/eb76de7df5c61660531dc6ecc992b6c46afef674)) ([#403](https://github.com/apache/plc4x-extras/pull/403)) + - bump org.eclipse.jetty:jetty-util from 12.0.22 to 12.0.23 (#401) ([d46b957](https://github.com/apache/plc4x-extras/commit/d46b95746f3fd3c1735fc51a86471100c49128f5)) ([#401](https://github.com/apache/plc4x-extras/pull/401)) + - bump org.checkerframework:checker-qual (#399) ([50e183b](https://github.com/apache/plc4x-extras/commit/50e183bb8556e5aada74a167974c5a3089731efe)) ([#399](https://github.com/apache/plc4x-extras/pull/399)) + - bump com.igormaznitsa:gosdk-wrapper-maven-plugin (#400) ([c20ac7b](https://github.com/apache/plc4x-extras/commit/c20ac7b35da27e9f8df86e49be0ef19ba7ca388e)) ([#400](https://github.com/apache/plc4x-extras/pull/400)) + - bump com.google.errorprone:error_prone_annotations (#394) ([d3c493e](https://github.com/apache/plc4x-extras/commit/d3c493e657b5a45725f830a1a71be8bd59ed1a69)) ([#394](https://github.com/apache/plc4x-extras/pull/394)) + - bump org.junit.jupiter:junit-jupiter-api (#395) ([39ac7f4](https://github.com/apache/plc4x-extras/commit/39ac7f4832bdb29adc9cf4b1bbb6fbf64cf5de9e)) ([#395](https://github.com/apache/plc4x-extras/pull/395)) + - bump org.junit:junit-bom from 5.13.1 to 5.13.2 (#396) ([d1fb6c5](https://github.com/apache/plc4x-extras/commit/d1fb6c5f318337f00b045c4eec2c242b8cafc90f)) ([#396](https://github.com/apache/plc4x-extras/pull/396)) + - bump kotlin.version from 2.1.21 to 2.2.0 (#393) ([d0ccab6](https://github.com/apache/plc4x-extras/commit/d0ccab64a1baa929ea32b63e384c352cff13ea44)) ([#393](https://github.com/apache/plc4x-extras/pull/393)) + - bump com.fasterxml.jackson.core:jackson-databind (#390) ([174c815](https://github.com/apache/plc4x-extras/commit/174c815decf698678869f642537239da2165ba52)) ([#390](https://github.com/apache/plc4x-extras/pull/390)) + - bump com.fasterxml.jackson.core:jackson-annotations (#392) ([4ab68e7](https://github.com/apache/plc4x-extras/commit/4ab68e76f865c773871e598e65195e74ec1eb74d)) ([#392](https://github.com/apache/plc4x-extras/pull/392)) + - bump com.hivemq:hivemq-mqtt-client from 1.3.6 to 1.3.7 (#388) ([4db52c4](https://github.com/apache/plc4x-extras/commit/4db52c4076bb7398ac0b43fae2e167b1dd7ffd17)) ([#388](https://github.com/apache/plc4x-extras/pull/388)) + - bump org.junit.jupiter:junit-jupiter-api (#384) ([9cc2036](https://github.com/apache/plc4x-extras/commit/9cc20369d622d527326aedfc2d76b326a2fd4898)) ([#384](https://github.com/apache/plc4x-extras/pull/384)) + - bump com.gradle:common-custom-user-data-maven-extension (#385) ([caea262](https://github.com/apache/plc4x-extras/commit/caea26297ab47172f1a10b67e60295f81b58dec7)) ([#385](https://github.com/apache/plc4x-extras/pull/385)) + - bump org.junit:junit-bom from 5.13.0 to 5.13.1 (#386) ([44acea4](https://github.com/apache/plc4x-extras/commit/44acea423d0712306cba487ae532483bf2389397)) ([#386](https://github.com/apache/plc4x-extras/pull/386)) + - bump org.bouncycastle:bcpkix-jdk18on from 1.80 to 1.81 (#380) ([0ea80db](https://github.com/apache/plc4x-extras/commit/0ea80db9c1db22e4fc929562125d31518976e096)) ([#380](https://github.com/apache/plc4x-extras/pull/380)) + - bump org.bouncycastle:bcprov-jdk18on from 1.80 to 1.81 (#379) ([bcc6bbb](https://github.com/apache/plc4x-extras/commit/bcc6bbbafdc9f68bcfeb4aa8ac64c813be6562f5)) ([#379](https://github.com/apache/plc4x-extras/pull/379)) + - bump org.bouncycastle:bcmail-jdk18on from 1.80 to 1.81 (#378) ([0686e2e](https://github.com/apache/plc4x-extras/commit/0686e2eb7a61e498caed062335427cbffa380f2d)) ([#378](https://github.com/apache/plc4x-extras/pull/378)) + - bump org.eclipse.jetty:jetty-util from 12.0.21 to 12.0.22 (#376) ([aee90a7](https://github.com/apache/plc4x-extras/commit/aee90a78e16078915b6b74b5a0783b530d24f5c0)) ([#376](https://github.com/apache/plc4x-extras/pull/376)) + - bump com.hivemq:hivemq-mqtt-client from 1.3.5 to 1.3.6 (#377) ([6075e04](https://github.com/apache/plc4x-extras/commit/6075e04b4e2630002fe836c4890e9da60f8e047d)) ([#377](https://github.com/apache/plc4x-extras/pull/377)) + - bump org.checkerframework:checker-qual (#372) ([7f4a50c](https://github.com/apache/plc4x-extras/commit/7f4a50c13cb31c96200859326d1814d2a3cbc09c)) ([#372](https://github.com/apache/plc4x-extras/pull/372)) + - bump io.nats:jnats from 2.21.1 to 2.21.2 (#374) ([0343545](https://github.com/apache/plc4x-extras/commit/0343545ea03b96f4d96488f2919716dc92af9913)) ([#374](https://github.com/apache/plc4x-extras/pull/374)) + - bump org.junit.jupiter:junit-jupiter-api (#375) ([55c10bc](https://github.com/apache/plc4x-extras/commit/55c10bce3fd3f0bc4d8f8969762d2c622ef4a76d)) ([#375](https://github.com/apache/plc4x-extras/pull/375)) + - bump com.google.protobuf:protobuf-java (#369) ([2090197](https://github.com/apache/plc4x-extras/commit/20901978ddee50ef7ac09c96990288f29d56b362)) ([#369](https://github.com/apache/plc4x-extras/pull/369)) + - bump calcite-core.version from 1.39.0 to 1.40.0 (#368) ([5c8a2b9](https://github.com/apache/plc4x-extras/commit/5c8a2b93493377512490542018eb702ffecabb91)) ([#368](https://github.com/apache/plc4x-extras/pull/368)) + - bump com.gradle:develocity-maven-extension (#370) ([2cc8e1f](https://github.com/apache/plc4x-extras/commit/2cc8e1f0407fa501b7e7f11a40c491a9ee3a4705)) ([#370](https://github.com/apache/plc4x-extras/pull/370)) + - bump org.junit:junit-bom from 5.12.2 to 5.13.0 (#371) ([167682c](https://github.com/apache/plc4x-extras/commit/167682c5d9f3d9aef3bdaf7441b5f5b7b472af46)) ([#371](https://github.com/apache/plc4x-extras/pull/371)) + - bump com.influxdb:influxdb-client-java from 7.2.0 to 7.3.0 (#367) ([46d6f1d](https://github.com/apache/plc4x-extras/commit/46d6f1d26635cff659b58eb1cbd83d7410401693)) ([#367](https://github.com/apache/plc4x-extras/pull/367)) + - bump org.junit:junit-bom from 5.12.1 to 5.12.2 (#366) ([f0aa29b](https://github.com/apache/plc4x-extras/commit/f0aa29b57227283eb5c2c99be252d0e1571b8aad)) ([#366](https://github.com/apache/plc4x-extras/pull/366)) + - bump io.nats:jnats from 2.21.0 to 2.21.1 (#365) ([a8d8d30](https://github.com/apache/plc4x-extras/commit/a8d8d309079bc0ae25caf1380fe1fd35bff82924)) ([#365](https://github.com/apache/plc4x-extras/pull/365)) + - bump com.gradle:develocity-maven-extension (#361) ([119cc62](https://github.com/apache/plc4x-extras/commit/119cc62baac98391bb44c5bad9bac83609eb3907)) ([#361](https://github.com/apache/plc4x-extras/pull/361)) + - bump com.google.protobuf:protobuf-java (#362) ([227c5db](https://github.com/apache/plc4x-extras/commit/227c5db694ba2c308baa01529f536f6f6f48098c)) ([#362](https://github.com/apache/plc4x-extras/pull/362)) + - bump io.swagger:swagger-annotations from 1.6.15 to 1.6.16 (#364) ([96d8114](https://github.com/apache/plc4x-extras/commit/96d8114613ed4c70c651e2635d919cad2be5ef13)) ([#364](https://github.com/apache/plc4x-extras/pull/364)) + - bump com.google.errorprone:error_prone_annotations (#358) ([5eea75f](https://github.com/apache/plc4x-extras/commit/5eea75f09fdb4b9c4862df365181690b9ca7eac2)) ([#358](https://github.com/apache/plc4x-extras/pull/358)) + - bump kotlin.version from 2.1.0 to 2.1.21 (#359) ([7d7aa85](https://github.com/apache/plc4x-extras/commit/7d7aa859cfd6a2ca55303cd15c884b26daa7d970)) ([#359](https://github.com/apache/plc4x-extras/pull/359)) + - bump org.junit.jupiter:junit-jupiter-api (#360) ([2ddc7cb](https://github.com/apache/plc4x-extras/commit/2ddc7cb0e1a1f6d92fc0557e8ce28536e2a9821b)) ([#360](https://github.com/apache/plc4x-extras/pull/360)) + - bump org.eclipse.milo:stack-core from 0.6.15 to 0.6.16 (#354) ([fee55a1](https://github.com/apache/plc4x-extras/commit/fee55a1f03372f3f98193bf3619a11e345bdb2f5)) ([#354](https://github.com/apache/plc4x-extras/pull/354)) + - bump com.fasterxml.jackson.core:jackson-annotations (#355) ([191ecf7](https://github.com/apache/plc4x-extras/commit/191ecf779bd6f7e1402bebc04ffd9e2981153ca8)) ([#355](https://github.com/apache/plc4x-extras/pull/355)) + - bump org.eclipse.jetty:jetty-util from 12.0.18 to 12.0.21 (#356) ([3f11b51](https://github.com/apache/plc4x-extras/commit/3f11b518f20239201ff207fb103cb6241692ee26)) ([#356](https://github.com/apache/plc4x-extras/pull/356)) + - bump org.checkerframework:checker-qual (#353) ([534c13f](https://github.com/apache/plc4x-extras/commit/534c13fba7b56ad50c6a359e13571f24408e6d4c)) ([#353](https://github.com/apache/plc4x-extras/pull/353)) + - bump org.junit:junit-bom from 5.12.1 to 5.12.2 (#352) ([d5aa6ee](https://github.com/apache/plc4x-extras/commit/d5aa6ee6dcc39de70594492813c5ec24a2491923)) ([#352](https://github.com/apache/plc4x-extras/pull/352)) + - bump org.eclipse.milo:sdk-core from 0.6.15 to 0.6.16 (#351) ([a1b9bb6](https://github.com/apache/plc4x-extras/commit/a1b9bb60f409971913bcd8da7b8b8d20152a67aa)) ([#351](https://github.com/apache/plc4x-extras/pull/351)) + - bump org.eclipse.milo:stack-server from 0.6.15 to 0.6.16 (#347) ([1d3183b](https://github.com/apache/plc4x-extras/commit/1d3183b04b39cdee91ca996298f401c2925d4373)) ([#347](https://github.com/apache/plc4x-extras/pull/347)) + - bump org.apache.commons:commons-configuration2 (#348) ([78df8db](https://github.com/apache/plc4x-extras/commit/78df8db37d04ba60aef55b97663ca5f658469961)) ([#348](https://github.com/apache/plc4x-extras/pull/348)) + - bump org.apache.commons:commons-collections4 (#349) ([25bf4ae](https://github.com/apache/plc4x-extras/commit/25bf4ae59b9f4636fbc70bf25e3d710aa755a306)) ([#349](https://github.com/apache/plc4x-extras/pull/349)) + - bump com.google.code.gson:gson from 2.12.1 to 2.13.1 (#344) ([04e866e](https://github.com/apache/plc4x-extras/commit/04e866e3f5906dec3cbb1f74cdc3ad2be680bd3c)) ([#344](https://github.com/apache/plc4x-extras/pull/344)) + - bump org.eclipse.milo:sdk-server from 0.6.15 to 0.6.16 (#345) ([575e71a](https://github.com/apache/plc4x-extras/commit/575e71a8453d1ad8f4e2ac43a37f8e19e30c8755)) ([#345](https://github.com/apache/plc4x-extras/pull/345)) + - bump com.gradle:common-custom-user-data-maven-extension (#346) ([ff66a43](https://github.com/apache/plc4x-extras/commit/ff66a43f85df50f7a6856c4c13950a96bb16dc92)) ([#346](https://github.com/apache/plc4x-extras/pull/346)) + - bump com.fasterxml.jackson.core:jackson-databind (#341) ([d6a1258](https://github.com/apache/plc4x-extras/commit/d6a12586aee69db02af67fb94a3049f83d7788a5)) ([#341](https://github.com/apache/plc4x-extras/pull/341)) + - bump commons-io:commons-io from 2.18.0 to 2.19.0 (#342) ([09fef5e](https://github.com/apache/plc4x-extras/commit/09fef5ef9b4b82044a58f090ff6755d03cc8c6f9)) ([#342](https://github.com/apache/plc4x-extras/pull/342)) + - bump org.eclipse.milo:dictionary-manager (#343) ([2bd5458](https://github.com/apache/plc4x-extras/commit/2bd5458ee1c9dc3d332812d8f580b94480d7d0d4)) ([#343](https://github.com/apache/plc4x-extras/pull/343)) + - bump io.netty:netty-common (#326) ([3ee2d2b](https://github.com/apache/plc4x-extras/commit/3ee2d2bb26cc2359511893037e5de456b0a5e0cb)) ([#326](https://github.com/apache/plc4x-extras/pull/326)) + - bump org.checkerframework:checker-qual (#323) ([35e6e2a](https://github.com/apache/plc4x-extras/commit/35e6e2a38a223f09ad9e6cf9bd71ac0165095ce5)) ([#323](https://github.com/apache/plc4x-extras/pull/323)) + - bump com.igormaznitsa:gosdk-wrapper-maven-plugin (#321) ([1627c20](https://github.com/apache/plc4x-extras/commit/1627c204bbea213b3c2c2c269ccca3d5b8696c3e)) ([#321](https://github.com/apache/plc4x-extras/pull/321)) + - bump joda-time:joda-time from 2.13.1 to 2.14.0 (#322) ([a29b1be](https://github.com/apache/plc4x-extras/commit/a29b1be17787f18ebd2341d5aca05d404c2b2efc)) ([#322](https://github.com/apache/plc4x-extras/pull/322)) + - bump github.com/spf13/viper in /plc4go (#318) ([6d6bea9](https://github.com/apache/plc4x-extras/commit/6d6bea997fe9b949a7e0c59251694368b7afc1dc)) ([#318](https://github.com/apache/plc4x-extras/pull/318)) + - bump com.google.protobuf:protobuf-java (#319) ([3db6a16](https://github.com/apache/plc4x-extras/commit/3db6a1683ac91cb18d23092a3cc9682e0a9591a3)) ([#319](https://github.com/apache/plc4x-extras/pull/319)) + - bump io.nats:jnats from 2.20.6 to 2.21.0 (#320) ([009a06f](https://github.com/apache/plc4x-extras/commit/009a06fa5e8383701e37640537fdbdbb132bc04b)) ([#320](https://github.com/apache/plc4x-extras/pull/320)) + - bump org.junit:junit-bom from 5.12.0 to 5.12.1 (#314) ([694d88e](https://github.com/apache/plc4x-extras/commit/694d88e072c90a83d789095a85fbd2737a9da47d)) ([#314](https://github.com/apache/plc4x-extras/pull/314)) + - bump com.fasterxml.jackson.core:jackson-annotations (#315) ([19cdfb6](https://github.com/apache/plc4x-extras/commit/19cdfb6b948ea3acdb90ca3e6c027b8b1842b975)) ([#315](https://github.com/apache/plc4x-extras/pull/315)) + - bump com.igormaznitsa:gosdk-wrapper-maven-plugin (#313) ([224b294](https://github.com/apache/plc4x-extras/commit/224b29454fbef985a8ccba38000da5e481de7317)) ([#313](https://github.com/apache/plc4x-extras/pull/313)) + - bump github.com/rs/zerolog from 1.33.0 to 1.34.0 in /plc4go (#316) ([6bf93b5](https://github.com/apache/plc4x-extras/commit/6bf93b57c3a09a15a66952bfd30d5ba946326380)) ([#316](https://github.com/apache/plc4x-extras/pull/316)) + - bump slf4j.version from 2.0.16 to 2.0.17 (#306) ([de4a935](https://github.com/apache/plc4x-extras/commit/de4a935f4fd7fec1985880599fbc99c15c65b31e)) ([#306](https://github.com/apache/plc4x-extras/pull/306)) + - bump ch.qos.logback:logback-core from 1.5.17 to 1.5.18 (#310) ([0977d81](https://github.com/apache/plc4x-extras/commit/0977d81077b6a200f6a8481bf99b4a436b9c2d45)) ([#310](https://github.com/apache/plc4x-extras/pull/310)) + - bump com.google.errorprone:error_prone_annotations (#312) ([6eac778](https://github.com/apache/plc4x-extras/commit/6eac7781718349c52ace2fa420581e0a56b845a6)) ([#312](https://github.com/apache/plc4x-extras/pull/312)) + - bump ch.qos.logback:logback-classic from 1.5.17 to 1.5.18 (#307) ([3ad38a1](https://github.com/apache/plc4x-extras/commit/3ad38a11f9841ce26f5d58d68494f9c86fbfe50e)) ([#307](https://github.com/apache/plc4x-extras/pull/307)) + - bump org.apache:apache from 33 to 34 (#308) ([a44cacb](https://github.com/apache/plc4x-extras/commit/a44cacb849dc9d043855a95e6a69fdb86d549427)) ([#308](https://github.com/apache/plc4x-extras/pull/308)) + - bump org.checkerframework:checker-qual (#309) ([4bea40a](https://github.com/apache/plc4x-extras/commit/4bea40ab8b2d788e94df8321cddeb743978a5580)) ([#309](https://github.com/apache/plc4x-extras/pull/309)) + - bump org.eclipse.jetty:jetty-util from 12.0.17 to 12.0.18 (#302) ([522fc28](https://github.com/apache/plc4x-extras/commit/522fc28172f3fc0defe735464b52549ca871a481)) ([#302](https://github.com/apache/plc4x-extras/pull/302)) + - bump io.nats:jnats from 2.20.5 to 2.20.6 (#303) ([3e067cc](https://github.com/apache/plc4x-extras/commit/3e067ccb537721155d910832935f5dd2e1d115cf)) ([#303](https://github.com/apache/plc4x-extras/pull/303)) + - bump org.junit.jupiter:junit-jupiter-api (#304) ([2077cae](https://github.com/apache/plc4x-extras/commit/2077caea20b4e397bde203907f796fefd7d95085)) ([#304](https://github.com/apache/plc4x-extras/pull/304)) + - bump com.fasterxml.jackson.core:jackson-databind (#305) ([982c230](https://github.com/apache/plc4x-extras/commit/982c230e75ba95064e9d5ff83e4f74f612ca49b9)) ([#305](https://github.com/apache/plc4x-extras/pull/305)) + - bump github.com/spf13/viper in /plc4go (#301) ([4b9f7a2](https://github.com/apache/plc4x-extras/commit/4b9f7a2fc52c1ee1ab9d58e1a24d2d0b358222a2)) ([#301](https://github.com/apache/plc4x-extras/pull/301)) + - bump ch.qos.logback:logback-classic from 1.5.16 to 1.5.17 (#297) ([925ee2d](https://github.com/apache/plc4x-extras/commit/925ee2d39dbfa79a090a8900d63b2644c73be799)) ([#297](https://github.com/apache/plc4x-extras/pull/297)) + - bump calcite-core.version from 1.38.0 to 1.39.0 (#298) ([ea518e9](https://github.com/apache/plc4x-extras/commit/ea518e9b6c5a9aea29d1469df48533a0ceaf9bfe)) ([#298](https://github.com/apache/plc4x-extras/pull/298)) + - bump github.com/spf13/cobra from 1.8.1 to 1.9.1 in /plc4go (#288) ([803278c](https://github.com/apache/plc4x-extras/commit/803278c5dd94c106de3d3a9723f946efba617b8f)) ([#288](https://github.com/apache/plc4x-extras/pull/288)) + - bump org.junit.jupiter:junit-jupiter-api (#289) ([8791bbd](https://github.com/apache/plc4x-extras/commit/8791bbd9e8ace9d09feb18332afe5c705fe750a5)) ([#289](https://github.com/apache/plc4x-extras/pull/289)) + - bump org.junit:junit-bom from 5.11.4 to 5.12.0 (#291) ([f6f2bbd](https://github.com/apache/plc4x-extras/commit/f6f2bbd7e89b7e1efcf292ccf9a08438fc6befd3)) ([#291](https://github.com/apache/plc4x-extras/pull/291)) + - bump com.google.protobuf:protobuf-java (#296) ([fabc751](https://github.com/apache/plc4x-extras/commit/fabc751328a32803c402028730cbcccf54e82859)) ([#296](https://github.com/apache/plc4x-extras/pull/296)) + - bump org.eclipse.jetty:jetty-util from 12.0.16 to 12.0.17 (#294) ([68e6e4a](https://github.com/apache/plc4x-extras/commit/68e6e4a71481e738de6b9fa7eec66c085f0852b7)) ([#294](https://github.com/apache/plc4x-extras/pull/294)) + - bump dorny/test-reporter from 1 to 2 (#295) ([ef9b510](https://github.com/apache/plc4x-extras/commit/ef9b510fec869de301ba36c0040d58db42b9b7b8)) ([#295](https://github.com/apache/plc4x-extras/pull/295)) + - bump ch.qos.logback:logback-core from 1.5.16 to 1.5.17 (#293) ([065c1d3](https://github.com/apache/plc4x-extras/commit/065c1d340ef60c962e8b50690fda9b1c2a268064)) ([#293](https://github.com/apache/plc4x-extras/pull/293)) + - bump com.gradle:develocity-maven-extension (#292) ([0f803b2](https://github.com/apache/plc4x-extras/commit/0f803b2d0abda3364e9e4995a90531b759e28029)) ([#292](https://github.com/apache/plc4x-extras/pull/292)) + - bump com.hivemq:hivemq-mqtt-client from 1.3.4 to 1.3.5 (#287) ([4b9a50e](https://github.com/apache/plc4x-extras/commit/4b9a50e58fbaf8a362fc5acf0cdfdf0578c4a992)) ([#287](https://github.com/apache/plc4x-extras/pull/287)) + - bump io.netty:netty-common (#284) ([fe64b67](https://github.com/apache/plc4x-extras/commit/fe64b6718e4b765b8427fc0926debaae26f0e1a0)) ([#284](https://github.com/apache/plc4x-extras/pull/284)) + - bump io.netty:netty-transport (#285) ([11bcf75](https://github.com/apache/plc4x-extras/commit/11bcf7580091f51804abd81b5eb3778335734cb4)) ([#285](https://github.com/apache/plc4x-extras/pull/285)) + - bump io.netty:netty-buffer (#286) ([c71e25a](https://github.com/apache/plc4x-extras/commit/c71e25a463abac95be1248464624dfca2155e6c7)) ([#286](https://github.com/apache/plc4x-extras/pull/286)) + - bump joda-time:joda-time from 2.13.0 to 2.13.1 (#282) ([013e493](https://github.com/apache/plc4x-extras/commit/013e493ac4aa4131fa4eb00ab733876661f3874c)) ([#282](https://github.com/apache/plc4x-extras/pull/282)) + - bump org.checkerframework:checker-qual (#283) ([31777f7](https://github.com/apache/plc4x-extras/commit/31777f7f7f988f1a25e14e93c456c35d932dc125)) ([#283](https://github.com/apache/plc4x-extras/pull/283)) + - bump io.vavr:vavr from 0.10.5 to 0.10.6 (#281) ([6e39566](https://github.com/apache/plc4x-extras/commit/6e395660706cf20708d07c1bf22d9980273d3924)) ([#281](https://github.com/apache/plc4x-extras/pull/281)) + - bump com.google.code.gson:gson from 2.12.0 to 2.12.1 (#280) ([aa32778](https://github.com/apache/plc4x-extras/commit/aa32778c245ec5f9953439b96e8733a615db3d9e)) ([#280](https://github.com/apache/plc4x-extras/pull/280)) + - bump com.google.code.gson:gson from 2.11.0 to 2.12.0 (#279) ([e4cba9c](https://github.com/apache/plc4x-extras/commit/e4cba9c82cb7d0aa62781ebedd9d0654abf789b8)) ([#279](https://github.com/apache/plc4x-extras/pull/279)) + - bump commons-codec:commons-codec from 1.17.2 to 1.18.0 (#278) ([ee749db](https://github.com/apache/plc4x-extras/commit/ee749db2ff86a89c937e38c6b4ea71514623917d)) ([#278](https://github.com/apache/plc4x-extras/pull/278)) + - bump com.gradle:develocity-maven-extension (#276) ([6cb1f5f](https://github.com/apache/plc4x-extras/commit/6cb1f5f09054ac4c72762d507d9e9c4b6b7ead9e)) ([#276](https://github.com/apache/plc4x-extras/pull/276)) + - bump org.assertj:assertj-core from 3.27.2 to 3.27.3 (#275) ([54ce764](https://github.com/apache/plc4x-extras/commit/54ce764e454de79264a118bf4f76753c3ffa0d79)) ([#275](https://github.com/apache/plc4x-extras/pull/275)) + - bump com.gradle:develocity-maven-extension (#274) ([7594b91](https://github.com/apache/plc4x-extras/commit/7594b9163b1201e72a2f512c5fddc4f167b36b86)) ([#274](https://github.com/apache/plc4x-extras/pull/274)) + - bump org.bouncycastle:bcprov-jdk18on from 1.79 to 1.80 (#273) ([b3db931](https://github.com/apache/plc4x-extras/commit/b3db931b1b7741ddbe6a2ee4d56c7932e26a36ca)) ([#273](https://github.com/apache/plc4x-extras/pull/273)) + - bump io.netty:netty-transport (#272) ([50441d8](https://github.com/apache/plc4x-extras/commit/50441d8582854d74b951fafc5c302ae49182e49c)) ([#272](https://github.com/apache/plc4x-extras/pull/272)) + - bump io.swagger:swagger-annotations from 1.6.14 to 1.6.15 (#271) ([6c710f1](https://github.com/apache/plc4x-extras/commit/6c710f1f61fdf1c482665ad5f170b06e08102429)) ([#271](https://github.com/apache/plc4x-extras/pull/271)) + - bump io.netty:netty-buffer (#268) ([ba4d597](https://github.com/apache/plc4x-extras/commit/ba4d597241cdd0918d6b9257057fccc43ef9c8e0)) ([#268](https://github.com/apache/plc4x-extras/pull/268)) + - bump org.bouncycastle:bcmail-jdk18on from 1.79 to 1.80 (#267) ([371f0c9](https://github.com/apache/plc4x-extras/commit/371f0c988eeab342b1eb49b3a00aca692f685a95)) ([#267](https://github.com/apache/plc4x-extras/pull/267)) + - bump org.bouncycastle:bcpkix-jdk18on from 1.79 to 1.80 (#269) ([1dfaef7](https://github.com/apache/plc4x-extras/commit/1dfaef7a15c8e0fdd27a85480842666550ea5e0a)) ([#269](https://github.com/apache/plc4x-extras/pull/269)) + - bump io.netty:netty-common (#270) ([93ae967](https://github.com/apache/plc4x-extras/commit/93ae96785ac68e4b647786ab21d3091ace9940c9)) ([#270](https://github.com/apache/plc4x-extras/pull/270)) + - bump github.com/gdamore/tcell/v2 in /plc4go (#265) ([5364268](https://github.com/apache/plc4x-extras/commit/5364268d007dc0e1e8b7d231c3b760dd624fb1bc)) ([#265](https://github.com/apache/plc4x-extras/pull/265)) + - bump github.com/schollz/progressbar/v3 in /plc4go (#262) ([ed9b417](https://github.com/apache/plc4x-extras/commit/ed9b4174e6df56cae52e4ee8d446e7449852800f)) ([#262](https://github.com/apache/plc4x-extras/pull/262)) + - bump github.com/gdamore/tcell/v2 in /plc4go (#263) ([2b1f7fa](https://github.com/apache/plc4x-extras/commit/2b1f7fabee060a3f5a588bcff3078e6170faf6fe)) ([#263](https://github.com/apache/plc4x-extras/pull/263)) + - bump com.google.protobuf:protobuf-java (#261) ([085a383](https://github.com/apache/plc4x-extras/commit/085a3833b725226c9f9bcfd11f1e75cddb209c27)) ([#261](https://github.com/apache/plc4x-extras/pull/261)) + - bump commons-codec:commons-codec from 1.17.1 to 1.17.2 (#259) ([27a75cc](https://github.com/apache/plc4x-extras/commit/27a75cc94cceca0f34df7a82965688daabb230be)) ([#259](https://github.com/apache/plc4x-extras/pull/259)) + - bump org.checkerframework:checker-qual (#255) ([3c22c55](https://github.com/apache/plc4x-extras/commit/3c22c55bce459776d4309ab0760fa7d61180d4fc)) ([#255](https://github.com/apache/plc4x-extras/pull/255)) + - bump ch.qos.logback:logback-classic from 1.5.15 to 1.5.16 (#256) ([af306f4](https://github.com/apache/plc4x-extras/commit/af306f4639b8bc15fe056ac9a2379142356dca09)) ([#256](https://github.com/apache/plc4x-extras/pull/256)) + - bump ch.qos.logback:logback-core from 1.5.15 to 1.5.16 (#257) ([8199952](https://github.com/apache/plc4x-extras/commit/819995291bcd7d900b205477e9c9348af7a33a8f)) ([#257](https://github.com/apache/plc4x-extras/pull/257)) + - bump org.assertj:assertj-core from 3.27.0 to 3.27.2 (#258) ([15cee41](https://github.com/apache/plc4x-extras/commit/15cee418cf6af0b69ef9b87cb514c6f8e98fad24)) ([#258](https://github.com/apache/plc4x-extras/pull/258)) + - bump io.netty:netty-common (#253) ([601701e](https://github.com/apache/plc4x-extras/commit/601701e776619f5db6bfd1349047116f8dc83f40)) ([#253](https://github.com/apache/plc4x-extras/pull/253)) + - bump io.netty:netty-buffer (#249) ([445a030](https://github.com/apache/plc4x-extras/commit/445a0302af766f29028c76c0f43e817bb0eb0e70)) ([#249](https://github.com/apache/plc4x-extras/pull/249)) + - bump ch.qos.logback:logback-classic from 1.5.14 to 1.5.15 (#250) ([f5b62c9](https://github.com/apache/plc4x-extras/commit/f5b62c9da2d40a458d5140f8d698d72e449057a2)) ([#250](https://github.com/apache/plc4x-extras/pull/250)) + - bump ch.qos.logback:logback-core from 1.5.14 to 1.5.15 (#251) ([063b3a2](https://github.com/apache/plc4x-extras/commit/063b3a2007c604fb258fa2179c36cadb9fc1a58e)) ([#251](https://github.com/apache/plc4x-extras/pull/251)) + - bump org.assertj:assertj-core from 3.26.3 to 3.27.0 (#252) ([39a96a9](https://github.com/apache/plc4x-extras/commit/39a96a9ece503c54a78a570ea4f420f8b909021f)) ([#252](https://github.com/apache/plc4x-extras/pull/252)) + - bump ch.qos.logback:logback-core from 1.5.12 to 1.5.14 (#247) ([df4456a](https://github.com/apache/plc4x-extras/commit/df4456a05b9301b669108ae3f6eca7c8e3f8c4f5)) ([#247](https://github.com/apache/plc4x-extras/pull/247)) + - bump io.netty:netty-transport (#243) ([83c5b42](https://github.com/apache/plc4x-extras/commit/83c5b4285e82266e8d8b8af9cc06df47c30dd572)) ([#243](https://github.com/apache/plc4x-extras/pull/243)) + - bump com.google.protobuf:protobuf-java (#246) ([8cecfe0](https://github.com/apache/plc4x-extras/commit/8cecfe08c367c0b237df51d9086c026a48d541ce)) ([#246](https://github.com/apache/plc4x-extras/pull/246)) + - bump ch.qos.logback:logback-classic from 1.5.12 to 1.5.14 (#248) ([a6d26f0](https://github.com/apache/plc4x-extras/commit/a6d26f0682f5291c3c66cf9797f52c8591f75270)) ([#248](https://github.com/apache/plc4x-extras/pull/248)) + - bump org.junit.jupiter:junit-jupiter-api (#240) ([eae6977](https://github.com/apache/plc4x-extras/commit/eae6977e49f75392eb66273b1409232a2c30d3d0)) ([#240](https://github.com/apache/plc4x-extras/pull/240)) + - bump org.junit:junit-bom from 5.11.3 to 5.11.4 (#241) ([74fcf85](https://github.com/apache/plc4x-extras/commit/74fcf8549d5c984e249027e6455df4b516c89b1d)) ([#241](https://github.com/apache/plc4x-extras/pull/241)) + - bump org.eclipse.jetty:jetty-util from 12.0.15 to 12.0.16 (#239) ([778d48b](https://github.com/apache/plc4x-extras/commit/778d48b54ec034d173f5908aa3138f1530b5fc93)) ([#239](https://github.com/apache/plc4x-extras/pull/239)) + - bump io.nats:jnats from 2.20.4 to 2.20.5 (#238) ([5afddce](https://github.com/apache/plc4x-extras/commit/5afddcec84206aab064ca3ef1d9023b136a6023d)) ([#238](https://github.com/apache/plc4x-extras/pull/238)) + - bump org.eclipse.milo:sdk-server from 0.6.14 to 0.6.15 (#237) ([a930293](https://github.com/apache/plc4x-extras/commit/a930293accbf34f51bd09e71e7ee627ff460fb57)) ([#237](https://github.com/apache/plc4x-extras/pull/237)) + - bump org.eclipse.milo:dictionary-manager (#236) ([f85fbb3](https://github.com/apache/plc4x-extras/commit/f85fbb31c7a6ff88f831e13fd5981441fb79fd17)) ([#236](https://github.com/apache/plc4x-extras/pull/236)) + - bump com.google.protobuf:protobuf-java (#235) ([69e2176](https://github.com/apache/plc4x-extras/commit/69e217680f0ea1fa19cc47e1621d5c3aa6950507)) ([#235](https://github.com/apache/plc4x-extras/pull/235)) + - bump org.eclipse.milo:stack-core from 0.6.14 to 0.6.15 (#234) ([886820a](https://github.com/apache/plc4x-extras/commit/886820a8ce62f8de90ac693e9f73a1d4e391252d)) ([#234](https://github.com/apache/plc4x-extras/pull/234)) + - bump org.eclipse.milo:sdk-core from 0.6.14 to 0.6.15 (#233) ([197ff93](https://github.com/apache/plc4x-extras/commit/197ff9309672bf149eb17651424564eb689d4ecd)) ([#233](https://github.com/apache/plc4x-extras/pull/233)) + - bump com.hivemq:hivemq-mqtt-client from 1.3.3 to 1.3.4 (#232) ([2734d64](https://github.com/apache/plc4x-extras/commit/2734d64a5f98ffb86835092bfeb3b5dd2f6ebfb1)) ([#232](https://github.com/apache/plc4x-extras/pull/232)) + - bump org.eclipse.milo:stack-server from 0.6.14 to 0.6.15 (#231) ([4ce9653](https://github.com/apache/plc4x-extras/commit/4ce9653f10d7a935b832b7f7e9293958a581eb67)) ([#231](https://github.com/apache/plc4x-extras/pull/231)) + - bump org.checkerframework:checker-qual (#230) ([225ae92](https://github.com/apache/plc4x-extras/commit/225ae9285493cb3728ded667bd051cdf9297461c)) ([#230](https://github.com/apache/plc4x-extras/pull/230)) + - bump org.cyclonedx:cyclonedx-maven-plugin (#227) ([04ff54e](https://github.com/apache/plc4x-extras/commit/04ff54eb6947b8937d7a7b7ee51e622c11ed4def)) ([#227](https://github.com/apache/plc4x-extras/pull/227)) + - bump com.fasterxml.jackson.core:jackson-annotations (#222) ([d0bd3a1](https://github.com/apache/plc4x-extras/commit/d0bd3a1ca1ee3e598f07657bf002857b2df2159b)) ([#222](https://github.com/apache/plc4x-extras/pull/222)) + - bump kotlin.version from 2.0.21 to 2.1.0 (#223) ([e51388d](https://github.com/apache/plc4x-extras/commit/e51388debb8f1d54a3cf8f968c58881e51da7fa9)) ([#223](https://github.com/apache/plc4x-extras/pull/223)) + - bump com.fasterxml.jackson.core:jackson-databind (#224) ([54fe5c5](https://github.com/apache/plc4x-extras/commit/54fe5c57dee11f61de342b6627469845bc3bb9c9)) ([#224](https://github.com/apache/plc4x-extras/pull/224)) + - bump com.google.protobuf:protobuf-java (#225) ([fc5279e](https://github.com/apache/plc4x-extras/commit/fc5279e3f4d5354617a234b04b0070efef044bcd)) ([#225](https://github.com/apache/plc4x-extras/pull/225)) + - bump github.com/stretchr/testify in /plc4go (#221) ([3e6f07d](https://github.com/apache/plc4x-extras/commit/3e6f07d1128c07014273bc126364731f20f90062)) ([#221](https://github.com/apache/plc4x-extras/pull/221)) + - bump nifi.version from 1.28.0 to 1.28.1 (#220) ([97bec46](https://github.com/apache/plc4x-extras/commit/97bec46900626a26b47c6a7f8161073b85d2682a)) ([#220](https://github.com/apache/plc4x-extras/pull/220)) + - bump commons-io:commons-io from 2.17.0 to 2.18.0 (#219) ([1abbf72](https://github.com/apache/plc4x-extras/commit/1abbf72f734bfaf19924ab0828d906c64f8417dc)) ([#219](https://github.com/apache/plc4x-extras/pull/219)) + - bump com.google.errorprone:error_prone_annotations (#218) ([63978ec](https://github.com/apache/plc4x-extras/commit/63978ec0f3d9218f20bd92f78eb6975517a80005)) ([#218](https://github.com/apache/plc4x-extras/pull/218)) + - bump io.netty:netty-common (#214) ([1a436be](https://github.com/apache/plc4x-extras/commit/1a436be6df2d37f2fa2c1a52ad253417bab94785)) ([#214](https://github.com/apache/plc4x-extras/pull/214)) + - bump io.netty:netty-transport (#215) ([e3ede46](https://github.com/apache/plc4x-extras/commit/e3ede465214ae80964ff9147550d3538eae1508c)) ([#215](https://github.com/apache/plc4x-extras/pull/215)) + - bump github.com/gopacket/gopacket in /plc4go (#217) ([59fece8](https://github.com/apache/plc4x-extras/commit/59fece82766727b51939ee574e60d44730874372)) ([#217](https://github.com/apache/plc4x-extras/pull/217)) + - bump github.com/schollz/progressbar/v3 in /plc4go (#213) ([88f80a0](https://github.com/apache/plc4x-extras/commit/88f80a0889549c0c61c947d42d1bd8d82db83bd9)) ([#213](https://github.com/apache/plc4x-extras/pull/213)) + - bump kafka.version from 3.8.1 to 3.9.0 (#212) ([ccbcb67](https://github.com/apache/plc4x-extras/commit/ccbcb67509c63cad3d74df071ad01fc0734d8afb)) ([#212](https://github.com/apache/plc4x-extras/pull/212)) + - bump org.eclipse.jetty:jetty-util from 12.0.14 to 12.0.15 (#211) ([3e0535c](https://github.com/apache/plc4x-extras/commit/3e0535cb2e4420b1db777f7239d1c52eee4101e5)) ([#211](https://github.com/apache/plc4x-extras/pull/211)) + - bump org.checkerframework:checker-qual (#209) ([2d5ab82](https://github.com/apache/plc4x-extras/commit/2d5ab82f03be6e249418f1ce0e1dc767c776033a)) ([#209](https://github.com/apache/plc4x-extras/pull/209)) + - bump io.nats:jnats from 2.20.2 to 2.20.4 (#210) ([9c11402](https://github.com/apache/plc4x-extras/commit/9c11402c26b727ff47c8d85b29804695cf0494c0)) ([#210](https://github.com/apache/plc4x-extras/pull/210)) + - bump nifi.version from 1.27.0 to 1.28.0 (#205) ([26d92b4](https://github.com/apache/plc4x-extras/commit/26d92b4dc69a225e415dc338db37467a478e1153)) ([#205](https://github.com/apache/plc4x-extras/pull/205)) + - bump org.bouncycastle:bcpkix-jdk18on from 1.78.1 to 1.79 (#206) ([6847a05](https://github.com/apache/plc4x-extras/commit/6847a052437528e83b17180e35628768f92f6b96)) ([#206](https://github.com/apache/plc4x-extras/pull/206)) + - bump org.bouncycastle:bcmail-jdk18on from 1.78.1 to 1.79 (#207) ([f30c7d3](https://github.com/apache/plc4x-extras/commit/f30c7d3c0282f21d480b1a5290523cdbb0cfccc4)) ([#207](https://github.com/apache/plc4x-extras/pull/207)) + - bump org.bouncycastle:bcprov-jdk18on from 1.78.1 to 1.79 (#208) ([a3769bb](https://github.com/apache/plc4x-extras/commit/a3769bb3b9e0122e0b96e1fc7c3ebac28b4abc89)) ([#208](https://github.com/apache/plc4x-extras/pull/208)) + - bump com.fasterxml.jackson.core:jackson-annotations (#203) ([a02f820](https://github.com/apache/plc4x-extras/commit/a02f820253c71df4873ef3d69028e0ac17b29052)) ([#203](https://github.com/apache/plc4x-extras/pull/203)) + - bump com.fasterxml.jackson.core:jackson-databind (#202) ([9475cec](https://github.com/apache/plc4x-extras/commit/9475cec0e8cfed7d28e688df2a9d66ac0129511b)) ([#202](https://github.com/apache/plc4x-extras/pull/202)) + - bump kafka.version from 3.8.0 to 3.8.1 (#204) ([9a562f7](https://github.com/apache/plc4x-extras/commit/9a562f71ea79672a1181daa40fa90608ad429278)) ([#204](https://github.com/apache/plc4x-extras/pull/204)) + - bump github.com/schollz/progressbar/v3 in /plc4go (#197) ([c2a169c](https://github.com/apache/plc4x-extras/commit/c2a169c47a4323fecf90a10fc6d2bd461ec5c54e)) ([#197](https://github.com/apache/plc4x-extras/pull/197)) + - bump com.google.errorprone:error_prone_annotations (#198) ([b452967](https://github.com/apache/plc4x-extras/commit/b45296713fe2ab609ba75e102681c5f5503955af)) ([#198](https://github.com/apache/plc4x-extras/pull/198)) + - bump ch.qos.logback:logback-classic from 1.5.11 to 1.5.12 (#199) ([f23ac99](https://github.com/apache/plc4x-extras/commit/f23ac99b85bae7b36d73e802db9ff56b1915751c)) ([#199](https://github.com/apache/plc4x-extras/pull/199)) + - bump ch.qos.logback:logback-core from 1.5.11 to 1.5.12 (#200) ([3595f2e](https://github.com/apache/plc4x-extras/commit/3595f2efec394d7e934af68b840262c8702417f6)) ([#200](https://github.com/apache/plc4x-extras/pull/200)) + - bump github.com/fatih/color in /plc4go (#193) ([bf26186](https://github.com/apache/plc4x-extras/commit/bf261865d7f302dcbb957bf64c7573bc0f56ac61)) ([#193](https://github.com/apache/plc4x-extras/pull/193)) + - bump com.google.protobuf:protobuf-java (#194) ([2e6488e](https://github.com/apache/plc4x-extras/commit/2e6488e5c51fd046422b26e2a5e8ab8ef696963d)) ([#194](https://github.com/apache/plc4x-extras/pull/194)) + - bump org.junit.jupiter:junit-jupiter-api (#189) ([62ca60c](https://github.com/apache/plc4x-extras/commit/62ca60cbba9bdd9dab668b8db8bedc493ab49a1b)) ([#189](https://github.com/apache/plc4x-extras/pull/189)) + - bump org.junit:junit-bom from 5.11.2 to 5.11.3 (#191) ([4ed4049](https://github.com/apache/plc4x-extras/commit/4ed4049028a2a79f3c7c3bf34894e31e70ea84ed)) ([#191](https://github.com/apache/plc4x-extras/pull/191)) + - bump com.google.errorprone:error_prone_annotations (#188) ([9a02a31](https://github.com/apache/plc4x-extras/commit/9a02a31da711d519055e137ff34ebb45176e76b1)) ([#188](https://github.com/apache/plc4x-extras/pull/188)) + - bump ch.qos.logback:logback-classic from 1.5.10 to 1.5.11 (#187) ([88c2701](https://github.com/apache/plc4x-extras/commit/88c2701883e18531b70ed3965c1bf58568ebfdd6)) ([#187](https://github.com/apache/plc4x-extras/pull/187)) + - bump ch.qos.logback:logback-core from 1.5.10 to 1.5.11 (#186) ([b24312f](https://github.com/apache/plc4x-extras/commit/b24312f15e7e7a65cfdf0a65bd89e297fa171886)) ([#186](https://github.com/apache/plc4x-extras/pull/186)) + - bump calcite-core.version from 1.37.0 to 1.38.0 (#184) ([f637ba8](https://github.com/apache/plc4x-extras/commit/f637ba804ac50480ebd294a8441e44b5d2bbe02d)) ([#184](https://github.com/apache/plc4x-extras/pull/184)) + - bump io.vavr:vavr from 0.10.4 to 0.10.5 (#185) ([d301f75](https://github.com/apache/plc4x-extras/commit/d301f75ac4a33533ad8601455eedbef1a704d953)) ([#185](https://github.com/apache/plc4x-extras/pull/185)) + - bump org.checkerframework:checker-qual (#182) ([5c489b4](https://github.com/apache/plc4x-extras/commit/5c489b44bd16cf86d12d574be241fdbd285a2fe2)) ([#182](https://github.com/apache/plc4x-extras/pull/182)) + - bump kotlin.version from 2.0.20 to 2.0.21 (#178) ([6946c9e](https://github.com/apache/plc4x-extras/commit/6946c9e6c4241db275f0e1ff66f3bb1625795a8c)) ([#178](https://github.com/apache/plc4x-extras/pull/178)) + - bump ch.qos.logback:logback-core from 1.5.9 to 1.5.10 (#181) ([9e4de64](https://github.com/apache/plc4x-extras/commit/9e4de64e8b1ed8c8273a12f835381aea22cf6787)) ([#181](https://github.com/apache/plc4x-extras/pull/181)) + - bump ch.qos.logback:logback-classic from 1.5.9 to 1.5.10 (#183) ([15e6e7a](https://github.com/apache/plc4x-extras/commit/15e6e7acdb351bd1af2c710dc80f95dca976f597)) ([#183](https://github.com/apache/plc4x-extras/pull/183)) + - bump ch.qos.logback:logback-classic from 1.5.8 to 1.5.9 (#176) ([c1942e0](https://github.com/apache/plc4x-extras/commit/c1942e01764cf8b8a7949e0384b1765a22398461)) ([#176](https://github.com/apache/plc4x-extras/pull/176)) + - bump org.cyclonedx:cyclonedx-maven-plugin (#177) ([e34a8fe](https://github.com/apache/plc4x-extras/commit/e34a8fe0e44c1e04f67392c76e147ffaa66097ae)) ([#177](https://github.com/apache/plc4x-extras/pull/177)) + - bump ch.qos.logback:logback-core from 1.5.8 to 1.5.9 (#175) ([8a1884f](https://github.com/apache/plc4x-extras/commit/8a1884f6a6e4404fb0c8ea84afcfde934498a017)) ([#175](https://github.com/apache/plc4x-extras/pull/175)) + - bump io.netty:netty-buffer (#173) ([85c27f3](https://github.com/apache/plc4x-extras/commit/85c27f3c9be7004f4cba0cb96a587c2d49ddb744)) ([#173](https://github.com/apache/plc4x-extras/pull/173)) + - bump org.junit.jupiter:junit-jupiter-api (#174) ([28e06b4](https://github.com/apache/plc4x-extras/commit/28e06b41fae73b5829fd98b6828a1dca6a5550a4)) ([#174](https://github.com/apache/plc4x-extras/pull/174)) + - bump io.netty:netty-transport (#172) ([f8155b6](https://github.com/apache/plc4x-extras/commit/f8155b60fa99c60a3586311ed071f5bcdd7f9eaa)) ([#172](https://github.com/apache/plc4x-extras/pull/172)) + - bump org.junit:junit-bom from 5.11.1 to 5.11.2 (#171) ([9d76e89](https://github.com/apache/plc4x-extras/commit/9d76e899a032a5a5762af90f197d59ac50962061)) ([#171](https://github.com/apache/plc4x-extras/pull/171)) + - bump org.checkerframework:checker-qual (#169) ([ed683b0](https://github.com/apache/plc4x-extras/commit/ed683b05782a1b29047be11bc5a271c1466a0b1f)) ([#169](https://github.com/apache/plc4x-extras/pull/169)) + - bump org.eclipse.jetty:jetty-util from 12.0.13 to 12.0.14 (#166) ([bf4a327](https://github.com/apache/plc4x-extras/commit/bf4a327a3a66d9f72524ca2432139664156bb4a8)) ([#166](https://github.com/apache/plc4x-extras/pull/166)) + - bump io.netty:netty-common (#165) ([3b7b49b](https://github.com/apache/plc4x-extras/commit/3b7b49b731c07ac34b78f9cdf34b9923d1a0868e)) ([#165](https://github.com/apache/plc4x-extras/pull/165)) + - bump commons-cli:commons-cli from 1.8.0 to 1.9.0 (#164) ([2abc146](https://github.com/apache/plc4x-extras/commit/2abc1460670caaf479e825320c13447ac18e1adb)) ([#164](https://github.com/apache/plc4x-extras/pull/164)) + - bump github.com/schollz/progressbar/v3 in /plc4go (#168) ([0c30981](https://github.com/apache/plc4x-extras/commit/0c30981691f4825e4de7e0b742bea4199f9fcbc3)) ([#168](https://github.com/apache/plc4x-extras/pull/168)) + - bump github.com/gopacket/gopacket from 1.2.0 to 1.3.0 in /plc4go (#127) ([e896bfe](https://github.com/apache/plc4x-extras/commit/e896bfecf8918907c3df089e1ea2ce13d14a5777)) ([#127](https://github.com/apache/plc4x-extras/pull/127)) + - bump org.apache:apache from 32 to 33 (#104) ([36c781d](https://github.com/apache/plc4x-extras/commit/36c781d8014b6afda57f4268b3f941bde55dfaa6)) ([#104](https://github.com/apache/plc4x-extras/pull/104)) + - bump com.google.errorprone:error_prone_annotations (#161) ([792a70d](https://github.com/apache/plc4x-extras/commit/792a70d1dbaf38d177b670a290e7a400c3e02f88)) ([#161](https://github.com/apache/plc4x-extras/pull/161)) + - bump org.cyclonedx:cyclonedx-maven-plugin (#162) ([2b0b5fb](https://github.com/apache/plc4x-extras/commit/2b0b5fb47226f6d734589fdb445d0c794f87f325)) ([#162](https://github.com/apache/plc4x-extras/pull/162)) + - bump com.fasterxml.jackson.core:jackson-databind (#163) ([f7b3f8b](https://github.com/apache/plc4x-extras/commit/f7b3f8bb094d79a8e3040f5ac6c351af1661bee1)) ([#163](https://github.com/apache/plc4x-extras/pull/163)) + - bump com.fasterxml.jackson.core:jackson-annotations (#158) ([1db21b5](https://github.com/apache/plc4x-extras/commit/1db21b55e74b8a9098bcec086077ab4efdef148e)) ([#158](https://github.com/apache/plc4x-extras/pull/158)) + - bump org.junit.jupiter:junit-jupiter-api (#159) ([5756800](https://github.com/apache/plc4x-extras/commit/5756800d5ad1ab7d96a40e23f4d255668b844639)) ([#159](https://github.com/apache/plc4x-extras/pull/159)) + - bump joda-time:joda-time from 2.12.7 to 2.13.0 (#160) ([c50b44d](https://github.com/apache/plc4x-extras/commit/c50b44d689a0d5651ebc290328f0da3e8c4f6098)) ([#160](https://github.com/apache/plc4x-extras/pull/160)) + - bump org.eclipse.jetty:jetty-util from 12.0.12 to 12.0.13 (#155) ([30e6551](https://github.com/apache/plc4x-extras/commit/30e65515be5ee6941c5cfcbde9219141ca91475b)) ([#155](https://github.com/apache/plc4x-extras/pull/155)) + - bump org.junit:junit-bom from 5.11.0 to 5.11.1 (#156) ([4914f0b](https://github.com/apache/plc4x-extras/commit/4914f0b1acc4843bc9c3a245901092d02bb85095)) ([#156](https://github.com/apache/plc4x-extras/pull/156)) + - bump com.gradle:common-custom-user-data-maven-extension (#152) ([095ffc2](https://github.com/apache/plc4x-extras/commit/095ffc223ad4d93bdb7be1fa0e6be2c97c3b39a1)) ([#152](https://github.com/apache/plc4x-extras/pull/152)) + - bump commons-io:commons-io from 2.16.1 to 2.17.0 (#153) ([3dcc148](https://github.com/apache/plc4x-extras/commit/3dcc1486170138cbbc88992bb67d4a3b5aabd719)) ([#153](https://github.com/apache/plc4x-extras/pull/153)) + - bump ch.qos.logback:logback-classic from 1.5.7 to 1.5.8 (#154) ([a6e5d10](https://github.com/apache/plc4x-extras/commit/a6e5d10218be00d1167f9704ee9e7ff0abe894e0)) ([#154](https://github.com/apache/plc4x-extras/pull/154)) + - bump ch.qos.logback:logback-core from 1.5.7 to 1.5.8 (#148) ([9729e80](https://github.com/apache/plc4x-extras/commit/9729e8085d3537c86fedf01b614d4151464e969b)) ([#148](https://github.com/apache/plc4x-extras/pull/148)) + - bump io.netty:netty-transport (#147) ([7bea9a1](https://github.com/apache/plc4x-extras/commit/7bea9a14dce6ca4a3117342fbd5c803807b3e0e7)) ([#147](https://github.com/apache/plc4x-extras/pull/147)) + - bump github.com/schollz/progressbar/v3 in /plc4go (#146) ([e775522](https://github.com/apache/plc4x-extras/commit/e77552273e8462b09aa163c4e19dd28791c4821b)) ([#146](https://github.com/apache/plc4x-extras/pull/146)) + - bump com.google.errorprone:error_prone_annotations (#149) ([64f461f](https://github.com/apache/plc4x-extras/commit/64f461f6c023329b92f267bd45ba576061cf505c)) ([#149](https://github.com/apache/plc4x-extras/pull/149)) + - bump org.eclipse.milo:stack-server from 0.6.13 to 0.6.14 (#143) ([57bad90](https://github.com/apache/plc4x-extras/commit/57bad909455a06559cf43304216cae034660147c)) ([#143](https://github.com/apache/plc4x-extras/pull/143)) + - bump slf4j.version from 2.0.13 to 2.0.16 (#144) ([3c71e9c](https://github.com/apache/plc4x-extras/commit/3c71e9c127d0a0b7b5dd9ea962944fcbb806f6a5)) ([#144](https://github.com/apache/plc4x-extras/pull/144)) + - bump io.netty:netty-buffer (#145) ([80ebcaf](https://github.com/apache/plc4x-extras/commit/80ebcaf8df4cf9cec92671c9fb014d59e1d49a6e)) ([#145](https://github.com/apache/plc4x-extras/pull/145)) + - bump com.google.protobuf:protobuf-java (#142) ([362191d](https://github.com/apache/plc4x-extras/commit/362191d62937dcb9c0dc01cc7a8112df0ab2143d)) ([#142](https://github.com/apache/plc4x-extras/pull/142)) + - bump org.apache.avro:avro from 1.11.3 to 1.12.0 (#123) ([ffb37a5](https://github.com/apache/plc4x-extras/commit/ffb37a5cfb141de2c71da7c5e0c7d4167d5ffb03)) ([#123](https://github.com/apache/plc4x-extras/pull/123)) + - bump org.junit.jupiter:junit-jupiter-api (#140) ([3bbb258](https://github.com/apache/plc4x-extras/commit/3bbb2589d1de992afd854cf4e4043ad8041ba934)) ([#140](https://github.com/apache/plc4x-extras/pull/140)) + - bump com.influxdb:influxdb-client-java from 7.1.0 to 7.2.0 (#139) ([42aaf46](https://github.com/apache/plc4x-extras/commit/42aaf462e9c13360839fbcbd8ea67b5ed20b1cdf)) ([#139](https://github.com/apache/plc4x-extras/pull/139)) + - bump github.com/schollz/progressbar/v3 in /plc4go (#141) ([d3cba9f](https://github.com/apache/plc4x-extras/commit/d3cba9f6ac52bb1da5b13b4d1e7f7fb36d245f30)) ([#141](https://github.com/apache/plc4x-extras/pull/141)) + - bump org.eclipse.milo:sdk-server from 0.6.13 to 0.6.14 (#138) ([a14696a](https://github.com/apache/plc4x-extras/commit/a14696af0e32c3f48d19afdd3d9ca5ced92c869f)) ([#138](https://github.com/apache/plc4x-extras/pull/138)) + - bump ch.qos.logback:logback-classic from 1.5.6 to 1.5.7 (#137) ([1c072a3](https://github.com/apache/plc4x-extras/commit/1c072a38166f5f2b920d7b10f8588cf964261ef3)) ([#137](https://github.com/apache/plc4x-extras/pull/137)) + - bump org.apache.commons:commons-lang3 from 3.15.0 to 3.17.0 (#135) ([5bdbba4](https://github.com/apache/plc4x-extras/commit/5bdbba44e84746b311da60a8a45203012b539dcb)) ([#135](https://github.com/apache/plc4x-extras/pull/135)) + - bump io.nats:jnats from 2.20.0 to 2.20.2 (#136) ([5928959](https://github.com/apache/plc4x-extras/commit/5928959dffdbcb74abc21726b33efdd989fc884f)) ([#136](https://github.com/apache/plc4x-extras/pull/136)) + - bump org.junit:junit-bom from 5.10.3 to 5.11.0 (#134) ([89c1b16](https://github.com/apache/plc4x-extras/commit/89c1b1683d8f8e20f07357fda7796f0502e03552)) ([#134](https://github.com/apache/plc4x-extras/pull/134)) + - bump kotlin.version from 2.0.0 to 2.0.20 (#132) ([621abbe](https://github.com/apache/plc4x-extras/commit/621abbe91107f16c6462d2f8645ed201d97ba560)) ([#132](https://github.com/apache/plc4x-extras/pull/132)) + - bump ch.qos.logback:logback-core from 1.5.6 to 1.5.7 (#131) ([7755f88](https://github.com/apache/plc4x-extras/commit/7755f8814f2809d5d2d395d262a0095b329bced4)) ([#131](https://github.com/apache/plc4x-extras/pull/131)) + - bump com.google.protobuf:protobuf-java (#129) ([019992a](https://github.com/apache/plc4x-extras/commit/019992a59e5f7245aca47a59871a1e9ab615747f)) ([#129](https://github.com/apache/plc4x-extras/pull/129)) + - bump com.google.errorprone:error_prone_annotations (#130) ([7b6e3cf](https://github.com/apache/plc4x-extras/commit/7b6e3cfc087d20117b27c9da1216fc95f26baa1b)) ([#130](https://github.com/apache/plc4x-extras/pull/130)) + - bump org.cyclonedx:cyclonedx-maven-plugin (#120) ([1084300](https://github.com/apache/plc4x-extras/commit/1084300a7d3abc5726f2fcb0fdf5f3fe435c8bc4)) ([#120](https://github.com/apache/plc4x-extras/pull/120)) + - bump org.eclipse.milo:sdk-core from 0.6.13 to 0.6.14 (#121) ([cf96a13](https://github.com/apache/plc4x-extras/commit/cf96a13e42987f25b35804bfe1e1c862ab4dd576)) ([#121](https://github.com/apache/plc4x-extras/pull/121)) + - bump github.com/schollz/progressbar/v3 in /plc4go (#122) ([d9ec3e9](https://github.com/apache/plc4x-extras/commit/d9ec3e9e507bb2d8bf7b7eaa2679722bd30fbeed)) ([#122](https://github.com/apache/plc4x-extras/pull/122)) + - bump org.eclipse.milo:dictionary-manager (#119) ([b5d29ca](https://github.com/apache/plc4x-extras/commit/b5d29ca64920dc544929a1243c86979dc5678c2d)) ([#119](https://github.com/apache/plc4x-extras/pull/119)) + - bump org.eclipse.milo:stack-core from 0.6.13 to 0.6.14 (#118) ([e74ceef](https://github.com/apache/plc4x-extras/commit/e74ceef3b6863a468756d772c5f06929ae339565)) ([#118](https://github.com/apache/plc4x-extras/pull/118)) + - bump kafka.version from 3.7.1 to 3.8.0 (#113) ([a91b95c](https://github.com/apache/plc4x-extras/commit/a91b95c58912b08010714f610429c870a1ece6ba)) ([#113](https://github.com/apache/plc4x-extras/pull/113)) + - bump org.eclipse.jetty:jetty-util from 12.0.11 to 12.0.12 (#115) ([4756e41](https://github.com/apache/plc4x-extras/commit/4756e4189f5fd08db2b8651b64f7fb1721dd0838)) ([#115](https://github.com/apache/plc4x-extras/pull/115)) + - bump io.nats:jnats from 2.19.1 to 2.20.0 (#116) ([977fff6](https://github.com/apache/plc4x-extras/commit/977fff6ed8014a1b172c5c0c4487dcb3f454d121)) ([#116](https://github.com/apache/plc4x-extras/pull/116)) + - bump github.com/schollz/progressbar/v3 in /plc4go (#114) ([7f2b760](https://github.com/apache/plc4x-extras/commit/7f2b7608ef2fd80c2031e522e35bc7d21c2f7c0d)) ([#114](https://github.com/apache/plc4x-extras/pull/114)) + - bump io.netty:netty-transport (#111) ([0b91f59](https://github.com/apache/plc4x-extras/commit/0b91f598ec6e097f965a94068299b66372a2d3cd)) ([#111](https://github.com/apache/plc4x-extras/pull/111)) + - bump io.netty:netty-common (#112) ([39c5f7e](https://github.com/apache/plc4x-extras/commit/39c5f7ee98c6e3f9eb808541fe4d9e91e526a510)) ([#112](https://github.com/apache/plc4x-extras/pull/112)) + - bump com.google.errorprone:error_prone_annotations (#108) ([4a6e226](https://github.com/apache/plc4x-extras/commit/4a6e226543e19dcf153a6670f97becb7080dc90e)) ([#108](https://github.com/apache/plc4x-extras/pull/108)) + - bump org.apache.commons:commons-lang3 from 3.14.0 to 3.15.0 (#109) ([e70b438](https://github.com/apache/plc4x-extras/commit/e70b438da205adf02c39e0ffd6a8326474dadeaa)) ([#109](https://github.com/apache/plc4x-extras/pull/109)) + - bump io.netty:netty-buffer (#110) ([8fea130](https://github.com/apache/plc4x-extras/commit/8fea1307b1be9a065fe0c206eeb9052919cc5263)) ([#110](https://github.com/apache/plc4x-extras/pull/110)) + - bump org.assertj:assertj-core from 3.26.0 to 3.26.3 (#103) ([be50205](https://github.com/apache/plc4x-extras/commit/be5020599279e3dda02948a5ecf67222de88c704)) ([#103](https://github.com/apache/plc4x-extras/pull/103)) + - bump commons-codec:commons-codec from 1.17.0 to 1.17.1 (#105) ([cf3c514](https://github.com/apache/plc4x-extras/commit/cf3c514c26f822c074080a4b2673b07533279b5e)) ([#105](https://github.com/apache/plc4x-extras/pull/105)) + - bump com.google.errorprone:error_prone_annotations (#107) ([b7fce27](https://github.com/apache/plc4x-extras/commit/b7fce279f8c376f75d584fbe32fe8150163f09f7)) ([#107](https://github.com/apache/plc4x-extras/pull/107)) + - bump nifi.version from 1.26.0 to 1.27.0 (#99) ([67f323a](https://github.com/apache/plc4x-extras/commit/67f323aa387c4be49627854fdb9f10dbfd92ee53)) ([#99](https://github.com/apache/plc4x-extras/pull/99)) + - bump com.fasterxml.jackson.core:jackson-databind (#100) ([9ff6feb](https://github.com/apache/plc4x-extras/commit/9ff6feb6e0808001d930fb70fcf455ab540f20c3)) ([#100](https://github.com/apache/plc4x-extras/pull/100)) + - bump com.fasterxml.jackson.core:jackson-annotations (#102) ([8b829a2](https://github.com/apache/plc4x-extras/commit/8b829a281279e5598c3ca2cb1b1643a08adcede2)) ([#102](https://github.com/apache/plc4x-extras/pull/102)) + - bump org.eclipse.jetty:jetty-util from 12.0.10 to 12.0.11 (#98) ([e58f98b](https://github.com/apache/plc4x-extras/commit/e58f98bd8d4a042323f96d8e0a27d33c2607a804)) ([#98](https://github.com/apache/plc4x-extras/pull/98)) + - bump org.checkerframework:checker-qual (#97) ([d868cca](https://github.com/apache/plc4x-extras/commit/d868cca9fda940fbbc8cc4bc61712c0c8d1dbb9b)) ([#97](https://github.com/apache/plc4x-extras/pull/97)) + - bump iotdb.version from 1.3.1 to 1.3.2 (#96) ([f68350d](https://github.com/apache/plc4x-extras/commit/f68350d277feac27ee7ea4b86680cce48a3821e3)) ([#96](https://github.com/apache/plc4x-extras/pull/96)) + - bump kafka.version from 3.7.0 to 3.7.1 (#95) ([0fbf815](https://github.com/apache/plc4x-extras/commit/0fbf815849e49563a85fff59d2095331f1006c52)) ([#95](https://github.com/apache/plc4x-extras/pull/95)) + - bump org.junit.jupiter:junit-jupiter-api (#93) ([6ba8b03](https://github.com/apache/plc4x-extras/commit/6ba8b03ecfde73322a8972840cc8f5a32212bda1)) ([#93](https://github.com/apache/plc4x-extras/pull/93)) + - bump org.junit:junit-bom from 5.10.2 to 5.10.3 (#94) ([90b5e68](https://github.com/apache/plc4x-extras/commit/90b5e68299888f63ffa7f675e4841dba4e1c7762)) ([#94](https://github.com/apache/plc4x-extras/pull/94)) + - bump io.nats:jnats from 2.19.0 to 2.19.1 (#90) ([c79de64](https://github.com/apache/plc4x-extras/commit/c79de646f87f16154c9d16c897da7f2986edcde8)) ([#90](https://github.com/apache/plc4x-extras/pull/90)) + - bump io.jsonwebtoken:jjwt-api from 0.12.5 to 0.12.6 (#89) ([ae59d98](https://github.com/apache/plc4x-extras/commit/ae59d98f32828a902f249316068e18addcf6ab21)) ([#89](https://github.com/apache/plc4x-extras/pull/89)) + - bump github.com/spf13/cobra from 1.8.0 to 1.8.1 in /plc4go (#88) ([9549ed1](https://github.com/apache/plc4x-extras/commit/9549ed1f7178b8c6d4265e1df376a50a5c7d08ea)) ([#88](https://github.com/apache/plc4x-extras/pull/88)) + - bump io.netty:netty-transport (#85) ([95ae61a](https://github.com/apache/plc4x-extras/commit/95ae61aff1c18eb03eaa3aa9cb3acac6c98a0c65)) ([#85](https://github.com/apache/plc4x-extras/pull/85)) + - bump io.netty:netty-buffer (#86) ([884d370](https://github.com/apache/plc4x-extras/commit/884d37025a51c71ed87eebbb7ed117bebd2bb0a1)) ([#86](https://github.com/apache/plc4x-extras/pull/86)) + - bump io.netty:netty-common (#87) ([ddfedfc](https://github.com/apache/plc4x-extras/commit/ddfedfcf65a098bfb89fd788abc2c3213abfacf8)) ([#87](https://github.com/apache/plc4x-extras/pull/87)) + - bump org.apache.commons:commons-configuration2 (#81) ([bab3af0](https://github.com/apache/plc4x-extras/commit/bab3af0778b1bdea4c65f6b0ba7bbfafc095bf16)) ([#81](https://github.com/apache/plc4x-extras/pull/81)) + - bump org.eclipse.jetty:jetty-util from 12.0.9 to 12.0.10 (#82) ([94f411f](https://github.com/apache/plc4x-extras/commit/94f411fc260d25704f011553f11603bc75913fbe)) ([#82](https://github.com/apache/plc4x-extras/pull/82)) + - bump io.nats:jnats from 2.18.1 to 2.19.0 (#83) ([5b5ec52](https://github.com/apache/plc4x-extras/commit/5b5ec528ee9ff7c3430a0875b2795abd262e6bff)) ([#83](https://github.com/apache/plc4x-extras/pull/83)) + - bump com.google.errorprone:error_prone_annotations (#84) ([134aee0](https://github.com/apache/plc4x-extras/commit/134aee048326cce4684d016c69d1e98a0839bdba)) ([#84](https://github.com/apache/plc4x-extras/pull/84)) + - bump org.eclipse.milo:stack-core from 0.6.12 to 0.6.13 (#72) ([a5bc6ac](https://github.com/apache/plc4x-extras/commit/a5bc6acc49b29e3029f772f9f715af35ed9837cd)) ([#72](https://github.com/apache/plc4x-extras/pull/72)) + - bump org.assertj:assertj-core from 3.25.3 to 3.26.0 (#73) ([528baa9](https://github.com/apache/plc4x-extras/commit/528baa9a3a63d6f9a38d91f7e87487e51f9e6d64)) ([#73](https://github.com/apache/plc4x-extras/pull/73)) + - bump commons-cli:commons-cli from 1.7.0 to 1.8.0 (#75) ([f386e83](https://github.com/apache/plc4x-extras/commit/f386e83d4708d12e386c96b8980e8d2c93f99a8f)) ([#75](https://github.com/apache/plc4x-extras/pull/75)) + - bump github.com/spf13/viper in /plc4go (#77) ([1839353](https://github.com/apache/plc4x-extras/commit/1839353e6df0de25cc78c714aa18b4f8ca9a61a4)) ([#77](https://github.com/apache/plc4x-extras/pull/77)) + - bump github.com/schollz/progressbar/v3 in /plc4go (#79) ([eef9108](https://github.com/apache/plc4x-extras/commit/eef9108d6c6b93af1ea08ccb16202c1342b18f98)) ([#79](https://github.com/apache/plc4x-extras/pull/79)) + - bump org.checkerframework:checker-qual (#80) ([d42833e](https://github.com/apache/plc4x-extras/commit/d42833e787453acd91098e1adb3792014903351e)) ([#80](https://github.com/apache/plc4x-extras/pull/80)) + - bump org.eclipse.milo:dictionary-manager (#67) ([df7bd3f](https://github.com/apache/plc4x-extras/commit/df7bd3f1d7ae2b175c614b09344d4b4f1fb3081b)) ([#67](https://github.com/apache/plc4x-extras/pull/67)) + - bump kotlin.version from 1.9.24 to 2.0.0 (#68) ([4059a54](https://github.com/apache/plc4x-extras/commit/4059a5452f846b4b85b13b767f2fd4e2910009af)) ([#68](https://github.com/apache/plc4x-extras/pull/68)) + - bump io.netty:netty-common (#69) ([406ca50](https://github.com/apache/plc4x-extras/commit/406ca5076d1a9da875005399df5032da574d70b2)) ([#69](https://github.com/apache/plc4x-extras/pull/69)) + - bump io.netty:netty-buffer (#70) ([7a6d8b1](https://github.com/apache/plc4x-extras/commit/7a6d8b170e5ee3dee3a08dabd52e7bff7ca9f243)) ([#70](https://github.com/apache/plc4x-extras/pull/70)) + - bump org.eclipse.milo:stack-server from 0.6.12 to 0.6.13 (#71) ([5f4010d](https://github.com/apache/plc4x-extras/commit/5f4010da7709b31761db1566daee62d5b50d3abf)) ([#71](https://github.com/apache/plc4x-extras/pull/71)) + - bump github.com/fatih/color in /plc4go (#59) ([0fac36b](https://github.com/apache/plc4x-extras/commit/0fac36b06f2ff7703f0a37a8ad5de77b4ceb7a92)) ([#59](https://github.com/apache/plc4x-extras/pull/59)) + - bump org.eclipse.milo:sdk-core from 0.6.12 to 0.6.13 (#60) ([7c83a04](https://github.com/apache/plc4x-extras/commit/7c83a048d7f9751ffe09c69d2fd299adbec92aaf)) ([#60](https://github.com/apache/plc4x-extras/pull/60)) + - bump com.google.code.gson:gson from 2.10.1 to 2.11.0 (#61) ([426b925](https://github.com/apache/plc4x-extras/commit/426b925c1dfcb56c638ccc04730b4df818bdf083)) ([#61](https://github.com/apache/plc4x-extras/pull/61)) + - bump org.eclipse.milo:sdk-server from 0.6.12 to 0.6.13 (#62) ([053b952](https://github.com/apache/plc4x-extras/commit/053b952b52bd86a41af92662e018800362f060e0)) ([#62](https://github.com/apache/plc4x-extras/pull/62)) + - bump com.influxdb:influxdb-client-java from 7.0.0 to 7.1.0 (#63) ([e670d07](https://github.com/apache/plc4x-extras/commit/e670d07c45751d302eccf6588f1f2ba8b3852e73)) ([#63](https://github.com/apache/plc4x-extras/pull/63)) + - bump github.com/rs/zerolog from 1.32.0 to 1.33.0 in /plc4go (#66) ([aa037c2](https://github.com/apache/plc4x-extras/commit/aa037c29c0f43660300e8a65f22a946323812631)) ([#66](https://github.com/apache/plc4x-extras/pull/66)) + - bump org.eclipse.jetty:jetty-util from 12.0.8 to 12.0.9 (#56) ([17a29cc](https://github.com/apache/plc4x-extras/commit/17a29cc049afcf4aa08d0e172ce487feaae1add8)) ([#56](https://github.com/apache/plc4x-extras/pull/56)) + - bump kotlin.version from 1.9.23 to 1.9.24 (#57) ([3a9b4d2](https://github.com/apache/plc4x-extras/commit/3a9b4d241aa1bdfacc4dae8a9ea050b5081fbdc3)) ([#57](https://github.com/apache/plc4x-extras/pull/57)) + - bump nifi.version from 1.25.0 to 1.26.0 (#53) ([2476287](https://github.com/apache/plc4x-extras/commit/247628717e47a7a1ac2e70fd2465c9de4ce42f46)) ([#53](https://github.com/apache/plc4x-extras/pull/53)) + - bump calcite-core.version from 1.36.0 to 1.37.0 (#54) ([1141292](https://github.com/apache/plc4x-extras/commit/1141292530e3bda8cebd8ccd6cd72d522adb683d)) ([#54](https://github.com/apache/plc4x-extras/pull/54)) + - bump com.fasterxml.jackson.core:jackson-databind (#49) ([c0ae0e2](https://github.com/apache/plc4x-extras/commit/c0ae0e228449ab6232f62c64b2dfceb61f8babff)) ([#49](https://github.com/apache/plc4x-extras/pull/49)) + - bump com.fasterxml.jackson.core:jackson-annotations (#50) ([1603fca](https://github.com/apache/plc4x-extras/commit/1603fca794a219ad53c5a5f9d751809012a85237)) ([#50](https://github.com/apache/plc4x-extras/pull/50)) + - bump com.google.errorprone:error_prone_annotations (#47) ([cbdceb1](https://github.com/apache/plc4x-extras/commit/cbdceb138b0300777c13994d4d2d6c6fd7af13d2)) ([#47](https://github.com/apache/plc4x-extras/pull/47)) + - bump org.checkerframework:checker-qual (#48) ([b639523](https://github.com/apache/plc4x-extras/commit/b639523791c987963c394883e7105b3f2c992d48)) ([#48](https://github.com/apache/plc4x-extras/pull/48)) + - bump com.google.errorprone:error_prone_annotations (#45) ([2cedc93](https://github.com/apache/plc4x-extras/commit/2cedc936947dc36114028891b7eb72fbccd8994d)) ([#45](https://github.com/apache/plc4x-extras/pull/45)) + - bump commons-codec:commons-codec from 1.16.1 to 1.17.0 (#46) ([130d41b](https://github.com/apache/plc4x-extras/commit/130d41b10304c16a2246d503248bf04d65eaa9a2)) ([#46](https://github.com/apache/plc4x-extras/pull/46)) + - bump org.apache.maven.plugins:maven-jar-plugin (#44) ([f7a0eaa](https://github.com/apache/plc4x-extras/commit/f7a0eaa1ab6302e3c12f83c16f15b86b6006f92f)) ([#44](https://github.com/apache/plc4x-extras/pull/44)) + - bump iotdb.version from 1.3.0 to 1.3.1 (#43) ([6ae19c8](https://github.com/apache/plc4x-extras/commit/6ae19c8849d19c8f08645aa7593b7191e928e3a6)) ([#43](https://github.com/apache/plc4x-extras/pull/43)) + - bump org.springframework.boot:spring-boot-dependencies (#36) ([68d27e1](https://github.com/apache/plc4x-extras/commit/68d27e1005261927bf34a7f0521c6996ae51a8a1)) ([#36](https://github.com/apache/plc4x-extras/pull/36)) + - bump org.bouncycastle:bcpkix-jdk18on from 1.78 to 1.78.1 (#37) ([fbd602b](https://github.com/apache/plc4x-extras/commit/fbd602be1780958a5342af2b5a3aea57dfe94d6e)) ([#37](https://github.com/apache/plc4x-extras/pull/37)) + - bump org.springframework.boot:spring-boot-maven-plugin (#38) ([64593eb](https://github.com/apache/plc4x-extras/commit/64593eb61438d1dc420a9a62ce908ffbdeb7ed83)) ([#38](https://github.com/apache/plc4x-extras/pull/38)) + - bump org.bouncycastle:bcmail-jdk18on from 1.78 to 1.78.1 (#39) ([c62bd33](https://github.com/apache/plc4x-extras/commit/c62bd3377c4a5c03e66162cf1749333af2518eb0)) ([#39](https://github.com/apache/plc4x-extras/pull/39)) + - bump org.bouncycastle:bcprov-jdk18on from 1.78 to 1.78.1 (#40) ([bbdd2b3](https://github.com/apache/plc4x-extras/commit/bbdd2b3c36bb9b87f270d48eeedbd10ad9705a8d)) ([#40](https://github.com/apache/plc4x-extras/pull/40)) + - bump ch.qos.logback:logback-classic from 1.5.5 to 1.5.6 (#35) ([8392ee8](https://github.com/apache/plc4x-extras/commit/8392ee8e20c4096f0f4b4e5c1d5017de35163a01)) ([#35](https://github.com/apache/plc4x-extras/pull/35)) + - bump ch.qos.logback:logback-core from 1.5.5 to 1.5.6 (#34) ([c783d12](https://github.com/apache/plc4x-extras/commit/c783d123daae656e1295da542abf214eabdabea1)) ([#34](https://github.com/apache/plc4x-extras/pull/34)) + - bump commons-cli:commons-cli from 1.6.0 to 1.7.0 (#33) ([66d8f14](https://github.com/apache/plc4x-extras/commit/66d8f14b08f01d4754f11b6a54b6c36d121e252c)) ([#33](https://github.com/apache/plc4x-extras/pull/33)) + - bump io.netty:netty-common (#31) ([b0ba638](https://github.com/apache/plc4x-extras/commit/b0ba63849e8c4701b5a6b21d2e870cf9426edc7a)) ([#31](https://github.com/apache/plc4x-extras/pull/31)) + - bump org.apache:apache from 31 to 32 (#32) ([fa276f3](https://github.com/apache/plc4x-extras/commit/fa276f31a6e1ca89fb6d4ed269f8d48fcdd3864b)) ([#32](https://github.com/apache/plc4x-extras/pull/32)) + - bump com.fasterxml.jackson.core:jackson-annotations (#30) ([663211e](https://github.com/apache/plc4x-extras/commit/663211e064b8c902a0f2322feb3ffd2b93f71fab)) ([#30](https://github.com/apache/plc4x-extras/pull/30)) + - bump io.netty:netty-transport (#29) ([508a825](https://github.com/apache/plc4x-extras/commit/508a82562ea6a4f5ca5d3d5586b630a49fd024f4)) ([#29](https://github.com/apache/plc4x-extras/pull/29)) + - bump ch.qos.logback:logback-core from 1.5.4 to 1.5.5 (#28) ([082565c](https://github.com/apache/plc4x-extras/commit/082565c5c52542be64977c5289119b6e09136e9e)) ([#28](https://github.com/apache/plc4x-extras/pull/28)) + - bump io.netty:netty-buffer (#26) ([f23c579](https://github.com/apache/plc4x-extras/commit/f23c5790600d6226bbd1b4accef7b062382ee12a)) ([#26](https://github.com/apache/plc4x-extras/pull/26)) + - bump slf4j.version from 2.0.12 to 2.0.13 (#22) ([2518786](https://github.com/apache/plc4x-extras/commit/2518786106334f83883d2ccb866150181072137c)) ([#22](https://github.com/apache/plc4x-extras/pull/22)) + - bump org.apache.maven.plugins:maven-jar-plugin (#25) ([f6ee4df](https://github.com/apache/plc4x-extras/commit/f6ee4df7e6601f3bdac2baf5b64dfe96f563b885)) ([#25](https://github.com/apache/plc4x-extras/pull/25)) + - bump jakarta.annotation:jakarta.annotation-api (#24) ([e885300](https://github.com/apache/plc4x-extras/commit/e885300f3414b3193bc72520e6b5a8d1567325b4)) ([#24](https://github.com/apache/plc4x-extras/pull/24)) + - bump io.nats:jnats from 2.17.4 to 2.17.6 (#23) ([b6b6c07](https://github.com/apache/plc4x-extras/commit/b6b6c076ca2d7dae8ebd6080f7c45798e09dc3e3)) ([#23](https://github.com/apache/plc4x-extras/pull/23)) + - bump ch.qos.logback:logback-classic from 1.5.4 to 1.5.5 (#21) ([862cccb](https://github.com/apache/plc4x-extras/commit/862cccb3cd3bbd7e7a591d714f546369c7edced4)) ([#21](https://github.com/apache/plc4x-extras/pull/21)) + - bump org.bouncycastle:bcpkix-jdk18on from 1.77 to 1.78 (#17) ([c2d06c4](https://github.com/apache/plc4x-extras/commit/c2d06c432299cfc61ef059feef3801549569e84a)) ([#17](https://github.com/apache/plc4x-extras/pull/17)) + - bump org.bouncycastle:bcmail-jdk18on from 1.77 to 1.78 (#18) ([314f7bc](https://github.com/apache/plc4x-extras/commit/314f7bc54f8aca50a33ee8285751ae2356c6ed2c)) ([#18](https://github.com/apache/plc4x-extras/pull/18)) + - bump ch.qos.logback:logback-classic from 1.5.3 to 1.5.4 (#19) ([9bfc8e1](https://github.com/apache/plc4x-extras/commit/9bfc8e1a92c0ff182e5fe45c7dc1582a4c22d3f1)) ([#19](https://github.com/apache/plc4x-extras/pull/19)) + - bump io.netty:netty-transport (#20) ([63ee96b](https://github.com/apache/plc4x-extras/commit/63ee96b2a4385f4e65110ea5f1c031c49cbb111d)) ([#20](https://github.com/apache/plc4x-extras/pull/20)) + - bump kafka.version from 3.2.0 to 3.7.0 (#14) ([7f71189](https://github.com/apache/plc4x-extras/commit/7f7118983426a0d0242ed9e29995b51349db327b)) ([#14](https://github.com/apache/plc4x-extras/pull/14)) + - bump org.bouncycastle:bcprov-jdk18on from 1.77 to 1.78 (#12) ([dddafab](https://github.com/apache/plc4x-extras/commit/dddafaba9cbf8ef1fbaeff68fc414db338ec90b1)) ([#12](https://github.com/apache/plc4x-extras/pull/12)) + - bump ch.qos.logback:logback-core from 1.5.3 to 1.5.4 (#16) ([431a569](https://github.com/apache/plc4x-extras/commit/431a569c732701f15fea0fbb168c42258c2bc4a3)) ([#16](https://github.com/apache/plc4x-extras/pull/16)) + - bump commons-io:commons-io from 2.16.0 to 2.16.1 (#15) ([fc6dd3e](https://github.com/apache/plc4x-extras/commit/fc6dd3e96130839f42f18bde663c065f52fbfaa8)) ([#15](https://github.com/apache/plc4x-extras/pull/15)) + - bump kotlin.version from 1.9.22 to 1.9.23 (#8) ([2b3e0ee](https://github.com/apache/plc4x-extras/commit/2b3e0ee91c20abcaf251907145257aae6c2762d1)) ([#8](https://github.com/apache/plc4x-extras/pull/8)) + - bump io.nats:jnats from 2.17.3 to 2.17.4 (#9) ([393f4e7](https://github.com/apache/plc4x-extras/commit/393f4e74a5df16c98b772aa9d279e52653d3eb09)) ([#9](https://github.com/apache/plc4x-extras/pull/9)) + - bump com.gradle:common-custom-user-data-maven-extension (#10) ([12bff9b](https://github.com/apache/plc4x-extras/commit/12bff9bb03317cc499a97b1865619b008d1474d4)) ([#10](https://github.com/apache/plc4x-extras/pull/10)) + - bump org.codehaus.mojo:extra-enforcer-rules (#11) ([03a9340](https://github.com/apache/plc4x-extras/commit/03a93403300914439b9389e6d57c62736ec5aabc)) ([#11](https://github.com/apache/plc4x-extras/pull/11)) + - bump com.microsoft.azure.sdk.iot:iot-device-client (#6) ([16be5eb](https://github.com/apache/plc4x-extras/commit/16be5eb69276a8fdbef42826792f5556fa4bc0a3)) ([#6](https://github.com/apache/plc4x-extras/pull/6)) + - bump commons-io:commons-io from 2.15.1 to 2.16.0 (#5) ([7965213](https://github.com/apache/plc4x-extras/commit/79652138456ec990551f0dd69daad236160e546f)) ([#5](https://github.com/apache/plc4x-extras/pull/5)) + - bump org.eclipse.jetty:jetty-util from 12.0.5 to 12.0.8 (#4) ([0ba0341](https://github.com/apache/plc4x-extras/commit/0ba0341540604f453400adcaf02cde4b9a1e7131)) ([#4](https://github.com/apache/plc4x-extras/pull/4)) + - bump io.netty:netty-common (#3) ([381cef3](https://github.com/apache/plc4x-extras/commit/381cef38c9bd14b8db94998b4672f01505dbe135)) ([#3](https://github.com/apache/plc4x-extras/pull/3)) + - bump com.fasterxml.jackson.core:jackson-databind (#2) ([c0c2510](https://github.com/apache/plc4x-extras/commit/c0c25107534ca92d4faeca65ba00716e722a92f3)) ([#2](https://github.com/apache/plc4x-extras/pull/2)) + - Bump org.apache.commons:commons-configuration2 (#1) ([21ac947](https://github.com/apache/plc4x-extras/commit/21ac947e354d89b7ad7d44587ef8a3ec66434902)) ([#1](https://github.com/apache/plc4x-extras/pull/1)) + +- general: + - Minor updating of stuff in the extras repo. ([10fce6a](https://github.com/apache/plc4x-extras/commit/10fce6ae8c291ce0e28ad7af2d92c0f4ce7ab1d4)) + - Updated to netty 4.1.119 ([705378b](https://github.com/apache/plc4x-extras/commit/705378b0d27a885255572eaf1d5528f21b9e5015)) + - Streamlined the Apache headers. ([3d4a73f](https://github.com/apache/plc4x-extras/commit/3d4a73f09fa78d622bd6ade021329e69085cd863)) + - Added an "empty parent path" to the root of the project, to allow higher level reactor poms. ([4af8823](https://github.com/apache/plc4x-extras/commit/4af88238fcacc5ba6090082f63d3b3e6b8bc9edf)) + - Disabled the UI module and made sure the opcua-server is only built on Java 17 or higher. ([9d86ed6](https://github.com/apache/plc4x-extras/commit/9d86ed6eba75e471b4bd574d5391ea62a2506625)) + - Cleanup in the self activating os-profiles. ([176a878](https://github.com/apache/plc4x-extras/commit/176a878d391ac6f9b83b56fe08ede5d53517da9f)) + - Got the build working ([b689b86](https://github.com/apache/plc4x-extras/commit/b689b8612cc9ecea7e1ac042f3bda9984bbba936)) + - Added "since" information. ([d94e4f5](https://github.com/apache/plc4x-extras/commit/d94e4f5518743408265d7145d29b51f7645d564d)) + - Tried getting the go build working. ([d88d5a0](https://github.com/apache/plc4x-extras/commit/d88d5a0f8199a63c72589d4ad03596f7781add03)) + - Switched the banch name to "develop" ([58ee31d](https://github.com/apache/plc4x-extras/commit/58ee31d908d33c222efd03eb1b4b0fd331f2e7b0)) + - Try to get the GitHub actions working ([fb42fe4](https://github.com/apache/plc4x-extras/commit/fb42fe477867a399740de49eb61c8a24bb93d7eb)) + - Try to get the GitHub actions working ([c97a142](https://github.com/apache/plc4x-extras/commit/c97a1421320b7f49ebf99b8ad2a360981bcd8d18)) + - add mvn wrapper ([326a0fb](https://github.com/apache/plc4x-extras/commit/326a0fb05a4607840d9476d6e243f70193d5e11a)) + - add mvn wrapper ([2add649](https://github.com/apache/plc4x-extras/commit/2add6493b0c6fd16737b3397af271ae83601cacc)) + - add actions ([6945457](https://github.com/apache/plc4x-extras/commit/69454572b3bcc9ee35abf6956fc583b462871a5a)) + - gitignore + tidy ([20e31af](https://github.com/apache/plc4x-extras/commit/20e31af149e70f46d9c28bd2bb96adc8d12170f4)) + - Get the Java part building again. ([cf8ca70](https://github.com/apache/plc4x-extras/commit/cf8ca709780ba40a7803fa098dc442d2ecd8b3db)) + +- deps-dev: + - bump org.junit.jupiter:junit-jupiter-engine (#419) ([7bdf143](https://github.com/apache/plc4x-extras/commit/7bdf143811ab4e26e0c2833f9428d568e6d7a344)) ([#419](https://github.com/apache/plc4x-extras/pull/419)) + - bump com.fasterxml.jackson.dataformat:jackson-dataformat-yaml (#414) ([ae8c89e](https://github.com/apache/plc4x-extras/commit/ae8c89e4dd87d86d323eac1d8700ffc20c953423)) ([#414](https://github.com/apache/plc4x-extras/pull/414)) + - bump org.junit.jupiter:junit-jupiter-engine (#404) ([f3d496b](https://github.com/apache/plc4x-extras/commit/f3d496bc2abc6d461a4045d57f35eccbc829ac24)) ([#404](https://github.com/apache/plc4x-extras/pull/404)) + - bump org.junit.jupiter:junit-jupiter-engine (#397) ([740290e](https://github.com/apache/plc4x-extras/commit/740290eb046c644d2216e4ed29cc76976e0480d4)) ([#397](https://github.com/apache/plc4x-extras/pull/397)) + - bump com.fasterxml.jackson.dataformat:jackson-dataformat-yaml (#391) ([64e1fcf](https://github.com/apache/plc4x-extras/commit/64e1fcf30b3641da28be577c7e64722f6213ecb5)) ([#391](https://github.com/apache/plc4x-extras/pull/391)) + - bump org.junit.jupiter:junit-jupiter-engine (#387) ([843c92a](https://github.com/apache/plc4x-extras/commit/843c92af6709785ed7b9eb3776f16ad8fbebe681)) ([#387](https://github.com/apache/plc4x-extras/pull/387)) + - bump org.junit.jupiter:junit-jupiter-engine (#373) ([43e9451](https://github.com/apache/plc4x-extras/commit/43e9451dc49ce4ca04a9b82fa412bcc74dff44ef)) ([#373](https://github.com/apache/plc4x-extras/pull/373)) + - bump com.fasterxml.jackson.dataformat:jackson-dataformat-yaml (#363) ([cea5b8a](https://github.com/apache/plc4x-extras/commit/cea5b8a09d38cbb221aa0ecb1df8663e0d1dd4b2)) ([#363](https://github.com/apache/plc4x-extras/pull/363)) + - bump org.junit.jupiter:junit-jupiter-engine (#357) ([ecc1d0a](https://github.com/apache/plc4x-extras/commit/ecc1d0a25c8518705393620c7706706f399af3ff)) ([#357](https://github.com/apache/plc4x-extras/pull/357)) + - bump org.junit.jupiter:junit-jupiter-engine (#299) ([89e5322](https://github.com/apache/plc4x-extras/commit/89e5322f4cb45714f475f703d138f71b75b64e5d)) ([#299](https://github.com/apache/plc4x-extras/pull/299)) + - bump com.fasterxml.jackson.dataformat:jackson-dataformat-yaml (#300) ([843b316](https://github.com/apache/plc4x-extras/commit/843b316059029f87e4bf2e6d7da6fcdf02bc7a45)) ([#300](https://github.com/apache/plc4x-extras/pull/300)) + - bump org.junit.jupiter:junit-jupiter-engine (#290) ([ba4725f](https://github.com/apache/plc4x-extras/commit/ba4725f252b4aff48934a36a8da59c33e27e3919)) ([#290](https://github.com/apache/plc4x-extras/pull/290)) + - bump org.junit.jupiter:junit-jupiter-engine (#242) ([4ee8ce0](https://github.com/apache/plc4x-extras/commit/4ee8ce027df57d47fdb7bf319c61720f4d80e15b)) ([#242](https://github.com/apache/plc4x-extras/pull/242)) + - bump com.fasterxml.jackson.dataformat:jackson-dataformat-yaml (#228) ([fe460c6](https://github.com/apache/plc4x-extras/commit/fe460c63f7e325ca7e27116b437a9160f244dc06)) ([#228](https://github.com/apache/plc4x-extras/pull/228)) + - bump com.fasterxml.jackson.dataformat:jackson-dataformat-yaml (#201) ([2a295ab](https://github.com/apache/plc4x-extras/commit/2a295abe65dc478e208faffc1cdecfbcd5c8f007)) ([#201](https://github.com/apache/plc4x-extras/pull/201)) + - bump org.junit.jupiter:junit-jupiter-engine (#190) ([bb7c371](https://github.com/apache/plc4x-extras/commit/bb7c371d31c50fa0e7a5510c77ad4f5789c5518f)) ([#190](https://github.com/apache/plc4x-extras/pull/190)) + - bump org.junit.jupiter:junit-jupiter-engine (#170) ([11a69a7](https://github.com/apache/plc4x-extras/commit/11a69a742ecfd36d78135b993d8e1d427adf3e21)) ([#170](https://github.com/apache/plc4x-extras/pull/170)) + - bump com.fasterxml.jackson.dataformat:jackson-dataformat-yaml (#167) ([0b6f5a0](https://github.com/apache/plc4x-extras/commit/0b6f5a0c9ad630df1fb3cda21043d2efae59b573)) ([#167](https://github.com/apache/plc4x-extras/pull/167)) + - bump org.junit.jupiter:junit-jupiter-engine (#157) ([e929c8b](https://github.com/apache/plc4x-extras/commit/e929c8b516d24cfa58c708a78ef42099f019f565)) ([#157](https://github.com/apache/plc4x-extras/pull/157)) + - bump org.junit.jupiter:junit-jupiter-engine (#133) ([4fb8e70](https://github.com/apache/plc4x-extras/commit/4fb8e70d1654885e5fde4278cf1996823bd513f0)) ([#133](https://github.com/apache/plc4x-extras/pull/133)) + - bump com.fasterxml.jackson.dataformat:jackson-dataformat-yaml (#101) ([712356d](https://github.com/apache/plc4x-extras/commit/712356dbdeb8a01059281a3ec6fc10a2e4e26fb4)) ([#101](https://github.com/apache/plc4x-extras/pull/101)) + - bump org.junit.jupiter:junit-jupiter-engine (#92) ([beb86fb](https://github.com/apache/plc4x-extras/commit/beb86fbccae7baf1b154ece3c53c619b810321a2)) ([#92](https://github.com/apache/plc4x-extras/pull/92)) + - bump com.fasterxml.jackson.dataformat:jackson-dataformat-yaml (#51) ([1e714ab](https://github.com/apache/plc4x-extras/commit/1e714abe70dea8ad1e05b1a56fa538490bf48315)) ([#51](https://github.com/apache/plc4x-extras/pull/51)) + - bump com.fasterxml.jackson.dataformat:jackson-dataformat-yaml (#27) ([b9fd084](https://github.com/apache/plc4x-extras/commit/b9fd08486391e8741874bbba5eb227636178a64a)) ([#27](https://github.com/apache/plc4x-extras/pull/27)) + - bump org.mockito:mockito-inline from 4.11.0 to 5.2.0 (#7) ([b7c18dc](https://github.com/apache/plc4x-extras/commit/b7c18dc49b4bad3e9ef787032238f3c9d5c209f1)) ([#7](https://github.com/apache/plc4x-extras/pull/7)) + +- build: + - remove codeql ([4482858](https://github.com/apache/plc4x-extras/commit/4482858bef21086dd0abdc8f5adda4588528f37f)) + - fix plc4go ([e653cbb](https://github.com/apache/plc4x-extras/commit/e653cbb703616400ddc329cbfab189ef83959bb9)) + +- plc4go: + - update tool versions ([975f7c0](https://github.com/apache/plc4x-extras/commit/975f7c0af1709eb44a72b17361fbffa8e221e52f)) + +\* *This CHANGELOG was automatically generated by [auto-generate-changelog](https://github.com/BobAnkh/auto-generate-changelog)* diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..51d12a634 --- /dev/null +++ b/LICENSE @@ -0,0 +1,204 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. + +=============================================================== \ No newline at end of file diff --git a/NOTICE b/NOTICE new file mode 100644 index 000000000..9fa88a2db --- /dev/null +++ b/NOTICE @@ -0,0 +1,8 @@ +Apache PLC4X +Copyright 2017-2026 The Apache Software Foundation + +This product includes software developed at +The Apache Software Foundation (http://www.apache.org/). + +---------------------------------------------- + diff --git a/README.md b/README.md new file mode 100644 index 000000000..787ffa801 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ + + +## Building PLC4X Extras + +For building all parts of PLC4X-Extras ensure you're running at least Java 21. +Maven 4.0.0-rc-6 or newer is required to build, so be sure it's installed and available on your system (the bundled Maven-Wrapper takes care of this automatically when using `./mvnw` or `mvnw` instead of the normal `mvn` command). + +Run the following command: + + ./mvnw -P with-c,with-go,with-java install + +## Licensing + +Apache PLC4X is released under the Apache License Version 2.0. diff --git a/RELEASE_NOTES b/RELEASE_NOTES new file mode 100644 index 000000000..1c788a3e4 --- /dev/null +++ b/RELEASE_NOTES @@ -0,0 +1,91 @@ +============================================================== +(Unreleased) Apache PLC4X Extras 1.0.0 +============================================================== + +New Features +------------ +- PLC4X Server: Now supports TLS as a transport (the default). + When no keystore is configured, the server generates an + ephemeral self-signed certificate on startup and logs its + SHA-256 fingerprint so clients can pin/trust it. Plaintext TCP + remains available as an explicit opt-in + (plc4x.server.plaintext=true). +- PLC4X Server: Now enforces mandatory username/password + authentication; no operation is permitted before a successful + auth handshake. If no credentials are configured, the server + starts with the default user "toddy" and a generated secure + password that is printed to the console once at startup. + Configured credentials are never logged. +- Go: Upgraded to Go 1.26 and re-synced to the latest upstream + PLC4X. + +Incompatible changes +-------------------- +- Dropped support for older Java versions, new baseline Java + version is Java 21. +- Migrated the build to Apache Maven 4. +- The S7-Event integration ("s7event") is out of sync with the + refactored (SPI3) driver and has been disabled; it is no longer + built or shipped. +- PLC4X Server: Was migrated from the SPI2 (Netty) core to SPI3. + It now listens via a plain ServerSocket/SSLServerSocket and no + longer depends on Netty. +- PLC4X Server: Authentication is now mandatory and TLS is the + default transport. Existing clients must provide credentials + and connect via TLS (or explicitly opt into plaintext). See the + matching note in the core 'plc4x' driver release notes. + +Changed Maven Coordinates +------------------------- + +- The Maven groupId of ALL extras modules changed from + "org.apache.plc4x" to "org.apache.plc4x.extras" so the extras + artifacts no longer collide with the main PLC4X repo. The + artifactIds are unchanged. Every consumer must update the + groupId in their dependency coordinates. + +Bug Fixes +--------- +- Kafka: The source and sink tasks no longer leak the connection + cache they create. Stopping a task now closes its + PlcConnectionCache, releasing the cached PLC connections. +- PLC4X Server: The connection cache is now created on start() + and closed on stop(), so stopping the server releases the + cached PLC connections instead of leaking them. +- OPC UA Server: The backend now closes its connection cache on + shutdown, and when its driver manager is replaced, instead of + leaking the cached PLC connections. +- PLC4X Server: Exclude BouncyCastle JAR signatures from the + shaded jar to avoid class-loading/verification errors when + loading the shaded artifact. + +============================================================== +Apache PLC4X 0.13.1 Extras +============================================================== + +New Features +------------ +- Updated to Apache PLC4X 0.13.1 + +Incompatible changes +-------------------- + +Bug Fixes +--------- + +============================================================== +Apache PLC4X 0.13.0 Extras +============================================================== + +New Features +------------ +- NiFi: Added NiFi 2 support + +Incompatible changes +-------------------- +- NiFi: Deprecate the non-record based processors +- NiFi: Removed internal Avro Schemas + +Bug Fixes +--------- +- NiFi: Integration - Fix array reading (#423) diff --git a/media/logos/300ppi/PLC4X.png b/media/logos/300ppi/PLC4X.png new file mode 100644 index 000000000..1bae6752a Binary files /dev/null and b/media/logos/300ppi/PLC4X.png differ diff --git a/mvnw b/mvnw index 8a8fb2282..33c353fae 100755 --- a/mvnw +++ b/mvnw @@ -19,298 +19,277 @@ # ---------------------------------------------------------------------------- # ---------------------------------------------------------------------------- -# Maven Start Up Batch script -# -# Required ENV vars: -# ------------------ -# JAVA_HOME - location of a JDK home dir +# Apache Maven Wrapper startup batch script, version 3.3.4 # # Optional ENV vars # ----------------- -# M2_HOME - location of maven2's installed home dir -# MAVEN_OPTS - parameters passed to the Java VM when running Maven -# e.g. to debug Maven itself, use -# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 -# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output # ---------------------------------------------------------------------------- -if [ -z "$MAVEN_SKIP_RC" ] ; then - - if [ -f /usr/local/etc/mavenrc ] ; then - . /usr/local/etc/mavenrc - fi - - if [ -f /etc/mavenrc ] ; then - . /etc/mavenrc - fi +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x - if [ -f "$HOME/.mavenrc" ] ; then - . "$HOME/.mavenrc" - fi +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac -fi +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + 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" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" -# OS specific support. $var _must_ be set to either true or false. -cygwin=false; -darwin=false; -mingw=false -case "`uname`" in - CYGWIN*) cygwin=true ;; - MINGW*) mingw=true;; - Darwin*) darwin=true - # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home - # See https://developer.apple.com/library/mac/qa/qa1170/_index.html - if [ -z "$JAVA_HOME" ]; then - if [ -x "/usr/libexec/java_home" ]; then - export JAVA_HOME="`/usr/libexec/java_home`" - else - export JAVA_HOME="/Library/Java/Home" + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 fi fi - ;; -esac - -if [ -z "$JAVA_HOME" ] ; then - if [ -r /etc/gentoo-release ] ; then - JAVA_HOME=`java-config --jre-home` + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi fi -fi - -if [ -z "$M2_HOME" ] ; then - ## resolve links - $0 may be a link to maven's home - PRG="$0" +} - # need this for relative symlinks - while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG="`dirname "$PRG"`/$link" - fi +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" done + printf %x\\n $h +} - saveddir=`pwd` +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } - M2_HOME=`dirname "$PRG"`/.. +die() { + printf %s\\n "$1" >&2 + exit 1 +} - # make it fully qualified - M2_HOME=`cd "$M2_HOME" && pwd` +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} - cd "$saveddir" - # echo Using m2 at $M2_HOME -fi +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac -# For Cygwin, ensure paths are in UNIX format before anything is touched -if $cygwin ; then - [ -n "$M2_HOME" ] && - M2_HOME=`cygpath --unix "$M2_HOME"` - [ -n "$JAVA_HOME" ] && - JAVA_HOME=`cygpath --unix "$JAVA_HOME"` - [ -n "$CLASSPATH" ] && - CLASSPATH=`cygpath --path --unix "$CLASSPATH"` -fi +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} -# For Mingw, ensure paths are in UNIX format before anything is touched -if $mingw ; then - [ -n "$M2_HOME" ] && - M2_HOME="`(cd "$M2_HOME"; pwd)`" - [ -n "$JAVA_HOME" ] && - JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" fi -if [ -z "$JAVA_HOME" ]; then - javaExecutable="`which javac`" - if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then - # readlink(1) is not available as standard on Solaris 10. - readLink=`which readlink` - if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then - if $darwin ; then - javaHome="`dirname \"$javaExecutable\"`" - javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" - else - javaExecutable="`readlink -f \"$javaExecutable\"`" - fi - javaHome="`dirname \"$javaExecutable\"`" - javaHome=`expr "$javaHome" : '\(.*\)/bin'` - JAVA_HOME="$javaHome" - export JAVA_HOME - fi - fi -fi +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac -if [ -z "$JAVACMD" ] ; then - 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 - else - JAVACMD="`\\unset -f command; \\command -v java`" - fi +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" fi -if [ ! -x "$JAVACMD" ] ; then - echo "Error: JAVA_HOME is not defined correctly." >&2 - echo " We cannot execute $JAVACMD" >&2 - exit 1 -fi +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" -if [ -z "$JAVA_HOME" ] ; then - echo "Warning: JAVA_HOME environment variable is not set." +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" fi -CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v -# traverses directory structure from process work directory to filesystem root -# first directory with .mvn subdirectory is considered project base directory -find_maven_basedir() { +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac - if [ -z "$1" ] - then - echo "Path not specified to find_maven_basedir" - return 1 - fi +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi - basedir="$1" - wdir="$1" - while [ "$wdir" != '/' ] ; do - if [ -d "$wdir"/.mvn ] ; then - basedir=$wdir - break +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true fi - # workaround for JBEAP-8937 (on Solaris 10/Sparc) - if [ -d "${wdir}" ]; then - wdir=`cd "$wdir/.."; pwd` + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true fi - # end of workaround - done - echo "${basedir}" -} - -# concatenates all lines of a file -concat_lines() { - if [ -f "$1" ]; then - echo "$(tr -s '\n' ' ' < "$1")" + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 fi -} - -BASE_DIR=`find_maven_basedir "$(pwd)"` -if [ -z "$BASE_DIR" ]; then - exit 1; fi -########################################################################################## -# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central -# This allows using the maven wrapper in projects that prohibit checking in binary data. -########################################################################################## -if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then - if [ "$MVNW_VERBOSE" = true ]; then - echo "Found .mvn/wrapper/maven-wrapper.jar" - fi +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" else - if [ "$MVNW_VERBOSE" = true ]; then - echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." - fi - if [ -n "$MVNW_REPOURL" ]; then - jarUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" - else - jarUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" - fi - while IFS="=" read key value; do - case "$key" in (wrapperUrl) jarUrl="$value"; break ;; - esac - done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" - if [ "$MVNW_VERBOSE" = true ]; then - echo "Downloading from: $jarUrl" - fi - wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" - if $cygwin; then - wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` - fi + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi - if command -v wget > /dev/null; then - if [ "$MVNW_VERBOSE" = true ]; then - echo "Found wget ... using wget" - fi - if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then - wget "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" - else - wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" - fi - elif command -v curl > /dev/null; then - if [ "$MVNW_VERBOSE" = true ]; then - echo "Found curl ... using curl" - fi - if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then - curl -o "$wrapperJarPath" "$jarUrl" -f - else - curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f - fi +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" - else - if [ "$MVNW_VERBOSE" = true ]; then - echo "Falling back to using Java to download" - fi - javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" - # For Cygwin, switch paths to Windows format before running javac - if $cygwin; then - javaClass=`cygpath --path --windows "$javaClass"` - fi - if [ -e "$javaClass" ]; then - if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then - if [ "$MVNW_VERBOSE" = true ]; then - echo " - Compiling MavenWrapperDownloader.java ..." - fi - # Compiling the Java class - ("$JAVA_HOME/bin/javac" "$javaClass") - fi - if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then - # Running the downloader - if [ "$MVNW_VERBOSE" = true ]; then - echo " - Running MavenWrapperDownloader.java ..." - fi - ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") - fi - fi - fi +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi fi -########################################################################################## -# End of extension -########################################################################################## -export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} -if [ "$MVNW_VERBOSE" = true ]; then - echo $MAVEN_PROJECTBASEDIR -fi -MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" - -# For Cygwin, switch paths to Windows format before running java -if $cygwin; then - [ -n "$M2_HOME" ] && - M2_HOME=`cygpath --path --windows "$M2_HOME"` - [ -n "$JAVA_HOME" ] && - JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` - [ -n "$CLASSPATH" ] && - CLASSPATH=`cygpath --path --windows "$CLASSPATH"` - [ -n "$MAVEN_PROJECTBASEDIR" ] && - MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f fi -# Provide a "standardized" way to retrieve the CLI args that will -# work with both Windows and non-Windows executions. -MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" -export MAVEN_CMD_LINE_ARGS +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi -WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" -exec "$JAVACMD" \ - $MAVEN_OPTS \ - $MAVEN_DEBUG_OPTS \ - -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ - "-Dmaven.home=${M2_HOME}" \ - "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ - ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" +clean || : +exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd index 1d8ab018e..bba35af22 100644 --- a/mvnw.cmd +++ b/mvnw.cmd @@ -1,3 +1,4 @@ +<# : batch portion @REM ---------------------------------------------------------------------------- @REM Licensed to the Apache Software Foundation (ASF) under one @REM or more contributor license agreements. See the NOTICE file @@ -18,171 +19,171 @@ @REM ---------------------------------------------------------------------------- @REM ---------------------------------------------------------------------------- -@REM Maven Start Up Batch script -@REM -@REM Required ENV vars: -@REM JAVA_HOME - location of a JDK home dir +@REM Apache Maven Wrapper startup batch script, version 3.3.4 @REM @REM Optional ENV vars -@REM M2_HOME - location of maven2's installed home dir -@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands -@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending -@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven -@REM e.g. to debug Maven itself, use -@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 -@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output @REM ---------------------------------------------------------------------------- -@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' -@echo off -@REM set title of command window -title %0 -@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' -@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% - -@REM set %HOME% to equivalent of $HOME -if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") - -@REM Execute a user defined script before this one -if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre -@REM check for pre script, once with legacy .bat ending and once with .cmd ending -if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* -if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* -:skipRcPre - -@setlocal - -set ERROR_CODE=0 - -@REM To isolate internal variables from possible post scripts, we use another setlocal -@setlocal - -@REM ==== START VALIDATION ==== -if not "%JAVA_HOME%" == "" goto OkJHome - -echo. -echo Error: JAVA_HOME not found in your environment. >&2 -echo Please set the JAVA_HOME variable in your environment to match the >&2 -echo location of your Java installation. >&2 -echo. -goto error - -:OkJHome -if exist "%JAVA_HOME%\bin\java.exe" goto init - -echo. -echo Error: JAVA_HOME is set to an invalid directory. >&2 -echo JAVA_HOME = "%JAVA_HOME%" >&2 -echo Please set the JAVA_HOME variable in your environment to match the >&2 -echo location of your Java installation. >&2 -echo. -goto error - -@REM ==== END VALIDATION ==== - -:init - -@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". -@REM Fallback to current working directory if not found. - -set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% -IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir - -set EXEC_DIR=%CD% -set WDIR=%EXEC_DIR% -:findBaseDir -IF EXIST "%WDIR%"\.mvn goto baseDirFound -cd .. -IF "%WDIR%"=="%CD%" goto baseDirNotFound -set WDIR=%CD% -goto findBaseDir - -:baseDirFound -set MAVEN_PROJECTBASEDIR=%WDIR% -cd "%EXEC_DIR%" -goto endDetectBaseDir - -:baseDirNotFound -set MAVEN_PROJECTBASEDIR=%EXEC_DIR% -cd "%EXEC_DIR%" - -:endDetectBaseDir - -IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig - -@setlocal EnableExtensions EnableDelayedExpansion -for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a -@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% - -:endReadAdditionalConfig - -SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" -set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" -set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain - -set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" - -FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( - IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B -) - -@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central -@REM This allows using the maven wrapper in projects that prohibit checking in binary data. -if exist %WRAPPER_JAR% ( - if "%MVNW_VERBOSE%" == "true" ( - echo Found %WRAPPER_JAR% - ) -) else ( - if not "%MVNW_REPOURL%" == "" ( - SET DOWNLOAD_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" - ) - if "%MVNW_VERBOSE%" == "true" ( - echo Couldn't find %WRAPPER_JAR%, downloading it ... - echo Downloading from: %DOWNLOAD_URL% - ) - - powershell -Command "&{"^ - "$webclient = new-object System.Net.WebClient;"^ - "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ - "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ - "}"^ - "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ - "}" - if "%MVNW_VERBOSE%" == "true" ( - echo Finished downloading %WRAPPER_JAR% - ) +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) ) -@REM End of extension - -@REM Provide a "standardized" way to retrieve the CLI args that will -@REM work with both Windows and non-Windows executions. -set MAVEN_CMD_LINE_ARGS=%* - -%MAVEN_JAVA_EXE% ^ - %JVM_CONFIG_MAVEN_PROPS% ^ - %MAVEN_OPTS% ^ - %MAVEN_DEBUG_OPTS% ^ - -classpath %WRAPPER_JAR% ^ - "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ - %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* -if ERRORLEVEL 1 goto error -goto end - -:error -set ERROR_CODE=1 - -:end -@endlocal & set ERROR_CODE=%ERROR_CODE% - -if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost -@REM check for post script, once with legacy .bat ending and once with .cmd ending -if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" -if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" -:skipRcPost - -@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' -if "%MAVEN_BATCH_PAUSE%"=="on" pause - -if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% - -cmd /C exit /B %ERROR_CODE% +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/plc4c/pom.xml b/plc4c/pom.xml index e74a9e2d3..ac5a0e31a 100644 --- a/plc4c/pom.xml +++ b/plc4c/pom.xml @@ -17,14 +17,12 @@ specific language governing permissions and limitations under the License. --> - - - 4.0.0 + - org.apache.plc4x + org.apache.plc4x.extras plc4x-extras-parent - 0.13.0-SNAPSHOT + 1.0.0-SNAPSHOT plc4c-extras @@ -34,7 +32,7 @@ Examples, integration modules and tools for C. - 2024-02-16T14:53:02Z + 2026-09-20T15:22:10Z 2.5.2 @@ -50,6 +48,7 @@ com.googlecode.maven-download-plugin download-maven-plugin + 1.13.0 @@ -136,7 +136,7 @@ - + @@ -198,6 +198,7 @@ org.apache.rat apache-rat-plugin + 0.18 cmake-*/** diff --git a/plc4go/.mockery.yaml b/plc4go/.mockery.yaml index 1c72391d5..cda5d1b9f 100644 --- a/plc4go/.mockery.yaml +++ b/plc4go/.mockery.yaml @@ -15,11 +15,20 @@ # specific language governing permissions and limitations # under the License. -all: True -mockname: "{{.Mock}}{{.InterfaceName}}" +with-expecter: true inpackage: True testonly: True -with-expecter: True +mockname: "{{.Mock}}{{.InterfaceName}}" boilerplate-file: .mockery.asl.header -exclude: - - protocols \ No newline at end of file + +packages: + github.com/apache/plc4x-extras/plc4go: + config: + recursive: true + include-regex: ".*.go" + exclude: + - .idea + - .git + - assets + - examples + - target \ No newline at end of file diff --git a/plc4j/tools/ui/frontend/project/src/pages/OpcUa.tsx b/plc4go/doc.go similarity index 89% rename from plc4j/tools/ui/frontend/project/src/pages/OpcUa.tsx rename to plc4go/doc.go index 24d79479a..050b1695f 100644 --- a/plc4j/tools/ui/frontend/project/src/pages/OpcUa.tsx +++ b/plc4go/doc.go @@ -17,8 +17,5 @@ * under the License. */ -export default function Inspect() { - return ( -

OPC-UA

- ) -} +// Package plc4go contains extra tools for https://github.com/apache/plc4x/plc4go +package plc4go diff --git a/plc4go/examples/ads/browse/Browse.go b/plc4go/examples/ads/browse/Browse.go index c7c3bf268..35e3d85c3 100644 --- a/plc4go/examples/ads/browse/Browse.go +++ b/plc4go/examples/ads/browse/Browse.go @@ -20,11 +20,14 @@ package main import ( + "context" + plc4go "github.com/apache/plc4x/plc4go/pkg/api" "github.com/apache/plc4x/plc4go/pkg/api/drivers" ) func main() { + ctx := context.Background() driverManager := plc4go.NewPlcDriverManager() defer func() { if err := driverManager.Close(); err != nil { @@ -32,13 +35,15 @@ func main() { } }() drivers.RegisterAdsDriver(driverManager) - connectionChan := driverManager.GetConnection("ads:tcp://192.168.23.20?sourceAmsNetId=192.168.23.200.1.1&sourceAmsPort=65534&targetAmsNetId=192.168.23.20.1.1&targetAmsPort=851") - connection := <-connectionChan - browseRequest, err := connection.GetConnection().BrowseRequestBuilder().AddQuery("all", "MAIN.rivianTest01.RotationalPosition").Build() + connection, err := driverManager.GetConnection(ctx, "ads:tcp://192.168.23.20?sourceAmsNetId=192.168.23.200.1.1&sourceAmsPort=65534&targetAmsNetId=192.168.23.20.1.1&targetAmsPort=851") + if err != nil { + panic(err) + } + browseRequest, err := connection.BrowseRequestBuilder().AddQuery("all", "MAIN.rivianTest01.RotationalPosition").Build() if err != nil { panic(err) } - browseResponseChannel := browseRequest.Execute() + browseResponseChannel := browseRequest.Execute(ctx) browseResponse := <-browseResponseChannel print(browseResponse) } diff --git a/plc4go/examples/ads/read/Read.go b/plc4go/examples/ads/read/Read.go index 96d82afcf..cf7276229 100644 --- a/plc4go/examples/ads/read/Read.go +++ b/plc4go/examples/ads/read/Read.go @@ -20,11 +20,14 @@ package main import ( + "context" + plc4go "github.com/apache/plc4x/plc4go/pkg/api" "github.com/apache/plc4x/plc4go/pkg/api/drivers" ) func main() { + ctx := context.Background() driverManager := plc4go.NewPlcDriverManager() defer func() { if err := driverManager.Close(); err != nil { @@ -32,9 +35,11 @@ func main() { } }() drivers.RegisterAdsDriver(driverManager) - connectionChan := driverManager.GetConnection("ads:tcp://192.168.23.20?sourceAmsNetId=192.168.23.200.1.1&sourceAmsPort=65534&targetAmsNetId=192.168.23.20.1.1&targetAmsPort=851") - connection := <-connectionChan - readRequest, err := connection.GetConnection().ReadRequestBuilder(). + connection, err := driverManager.GetConnection(ctx, "ads:tcp://192.168.23.20?sourceAmsNetId=192.168.23.200.1.1&sourceAmsPort=65534&targetAmsNetId=192.168.23.20.1.1&targetAmsPort=851") + if err != nil { + panic(err) + } + readRequest, err := connection.ReadRequestBuilder(). AddTagAddress("value-bool", "MAIN.hurz_BOOL"). AddTagAddress("value-byte", "MAIN.hurz_BYTE"). AddTagAddress("value-word", "MAIN.hurz_WORD"). @@ -62,7 +67,7 @@ func main() { if err != nil { panic(err) } - readResponseChannel := readRequest.Execute() + readResponseChannel := readRequest.Execute(ctx) readResult := <-readResponseChannel readResponse := readResult.GetResponse() for _, tagName := range readResponse.GetTagNames() { diff --git a/plc4go/examples/ads/subscribe/Subscribe.go b/plc4go/examples/ads/subscribe/Subscribe.go index c39eb30db..806196924 100644 --- a/plc4go/examples/ads/subscribe/Subscribe.go +++ b/plc4go/examples/ads/subscribe/Subscribe.go @@ -20,17 +20,18 @@ package main import ( + "context" "time" plc4go "github.com/apache/plc4x/plc4go/pkg/api" "github.com/apache/plc4x/plc4go/pkg/api/drivers" apiModel "github.com/apache/plc4x/plc4go/pkg/api/model" - "github.com/rs/zerolog" "github.com/rs/zerolog/log" ) func main() { + ctx := context.Background() zerolog.SetGlobalLevel(zerolog.InfoLevel) driverManager := plc4go.NewPlcDriverManager() @@ -40,10 +41,12 @@ func main() { } }() drivers.RegisterAdsDriver(driverManager) - connectionChan := driverManager.GetConnection("ads:tcp://192.168.23.20?sourceAmsNetId=192.168.23.200.1.1&sourceAmsPort=65534&targetAmsNetId=192.168.23.20.1.1&targetAmsPort=851") - connection := <-connectionChan + connection, err := driverManager.GetConnection(ctx, "ads:tcp://192.168.23.20?sourceAmsNetId=192.168.23.200.1.1&sourceAmsPort=65534&targetAmsNetId=192.168.23.20.1.1&targetAmsPort=851") + if err != nil { + panic(err) + } - subscriptionRequest, err := connection.GetConnection().SubscriptionRequestBuilder(). + subscriptionRequest, err := connection.SubscriptionRequestBuilder(). AddChangeOfStateTagAddress("value-int", "MAIN.rivianTest01.HorizontalPosition"). AddPreRegisteredConsumer("value-int", func(event apiModel.PlcSubscriptionEvent) { value := event.GetValue("value-int") @@ -53,7 +56,7 @@ func main() { if err != nil { panic(err) } - subscriptionResponseChannel := subscriptionRequest.Execute() + subscriptionResponseChannel := subscriptionRequest.Execute(ctx) subscriptionResult := <-subscriptionResponseChannel if subscriptionResult.GetErr() != nil { log.Error().Err(subscriptionResult.GetErr()).Msg("error in response") diff --git a/plc4go/examples/ads/write/Write.go b/plc4go/examples/ads/write/Write.go index a14eb53c1..79e02ee09 100644 --- a/plc4go/examples/ads/write/Write.go +++ b/plc4go/examples/ads/write/Write.go @@ -20,17 +20,18 @@ package main import ( + "context" "time" plc4go "github.com/apache/plc4x/plc4go/pkg/api" "github.com/apache/plc4x/plc4go/pkg/api/drivers" apiValues "github.com/apache/plc4x/plc4go/pkg/api/values" spiValues "github.com/apache/plc4x/plc4go/spi/values" - "github.com/rs/zerolog/log" ) func main() { + ctx := context.Background() driverManager := plc4go.NewPlcDriverManager() defer func() { if err := driverManager.Close(); err != nil { @@ -38,8 +39,10 @@ func main() { } }() drivers.RegisterAdsDriver(driverManager) - connectionChan := driverManager.GetConnection("ads:tcp://192.168.23.20?sourceAmsNetId=192.168.23.200.1.1&sourceAmsPort=65534&targetAmsNetId=192.168.23.20.1.1&targetAmsPort=851") - connection := <-connectionChan + connection, err := driverManager.GetConnection(ctx, "ads:tcp://192.168.23.20?sourceAmsNetId=192.168.23.200.1.1&sourceAmsPort=65534&targetAmsNetId=192.168.23.20.1.1&targetAmsPort=851") + if err != nil { + panic(err) + } duration, _ := time.ParseDuration("1.234S") lduration, _ := time.ParseDuration("24015H23M12.034002044S") @@ -71,7 +74,7 @@ func main() { children["hurz_DATE"] = spiValues.NewPlcDATE(date) children["hurz_TIME_OF_DAY"] = spiValues.NewPlcTIME_OF_DAY(timeOfDay) children["hurz_DATE_AND_TIME"] = spiValues.NewPlcDATE_AND_TIME(dateAndTime) - writeRequest, err := connection.GetConnection().WriteRequestBuilder(). + writeRequest, err := connection.WriteRequestBuilder(). AddTagAddress("value-bool", "MAIN.hurz_BOOL", spiValues.NewPlcBOOL(true)). // 1 AddTagAddress("value-byte", "MAIN.hurz_BYTE", spiValues.NewPlcBYTE(42)). // 1 AddTagAddress("value-word", "MAIN.hurz_WORD", spiValues.NewPlcWORD(42424)). // 2 @@ -99,7 +102,7 @@ func main() { if err != nil { panic(err) } - writeResponseChannel := writeRequest.Execute() + writeResponseChannel := writeRequest.Execute(ctx) writeResult := <-writeResponseChannel if writeResult.GetErr() != nil { log.Error().Err(writeResult.GetErr()).Msg("error in response") diff --git a/plc4go/examples/bacnet/discovery/hello_world_plc4go_bacnet_discovery.go b/plc4go/examples/bacnet/discovery/hello_world_plc4go_bacnet_discovery.go index 4e2c77b0a..4dd8a7e04 100644 --- a/plc4go/examples/bacnet/discovery/hello_world_plc4go_bacnet_discovery.go +++ b/plc4go/examples/bacnet/discovery/hello_world_plc4go_bacnet_discovery.go @@ -20,19 +20,20 @@ package main import ( + "context" "fmt" - "github.com/apache/plc4x/plc4go/spi/options" "os" "time" "github.com/apache/plc4x/plc4go/pkg/api" "github.com/apache/plc4x/plc4go/pkg/api/drivers" apiModel "github.com/apache/plc4x/plc4go/pkg/api/model" - + "github.com/apache/plc4x/plc4go/spi/options" "github.com/rs/zerolog/log" ) func main() { + ctx := context.Background() logger := log.With().Str("myCustomLogger", "example").Logger() driverManager := plc4go.NewPlcDriverManager( @@ -49,7 +50,7 @@ func main() { var connectionStrings []string if len(os.Args) < 2 { // Try to auto-find bacnet devices via broadcast-message discovery - if err := driverManager.Discover(func(event apiModel.PlcDiscoveryItem) { + if err := driverManager.Discover(ctx, func(event apiModel.PlcDiscoveryItem) { connStr := event.GetProtocolCode() + "://" + event.GetTransportUrl().Host log.Info().Str("connection string", connStr).Stringer("event", event.(fmt.Stringer)).Msg("Found Bacnet Gateway") @@ -76,16 +77,14 @@ func main() { for _, connStr := range connectionStrings { log.Info().Str("connection string", connStr).Msg("Connecting") - crc := driverManager.GetConnection(connStr) + connection, err := driverManager.GetConnection(ctx, connStr) // Wait for the driver to connect (or not) - connectionResult := <-crc - if connectionResult.GetErr() != nil { - log.Error().Err(connectionResult.GetErr()).Msg("error connecting to PLC") + if err != nil { + log.Error().Err(err).Msg("error connecting to PLC") return } log.Info().Str("connection string", connStr).Msg("Connected") - connection := connectionResult.GetConnection() - connection.BlockingClose() + connection.Close() } } diff --git a/plc4go/examples/knx/discovery/hello_world_plc4go_knx_discovery.go b/plc4go/examples/knx/discovery/hello_world_plc4go_knx_discovery.go index 2ccd9eeaf..b1df888f8 100644 --- a/plc4go/examples/knx/discovery/hello_world_plc4go_knx_discovery.go +++ b/plc4go/examples/knx/discovery/hello_world_plc4go_knx_discovery.go @@ -20,22 +20,20 @@ package main import ( + "context" "fmt" "os" "time" "github.com/apache/plc4x/plc4go/pkg/api" "github.com/apache/plc4x/plc4go/pkg/api/drivers" - "github.com/apache/plc4x/plc4go/pkg/api/logging" apiModel "github.com/apache/plc4x/plc4go/pkg/api/model" "github.com/apache/plc4x/plc4go/spi/utils" - "github.com/rs/zerolog/log" ) func main() { - // Set logging to INFO - logging.InfoLevel() + ctx := context.Background() driverManager := plc4go.NewPlcDriverManager() defer func() { @@ -48,7 +46,7 @@ func main() { var connectionStrings []string if len(os.Args) < 2 { // Try to auto-find KNX gateways via broadcast-message discovery - _ = driverManager.Discover(func(event apiModel.PlcDiscoveryItem) { + _ = driverManager.Discover(ctx, func(event apiModel.PlcDiscoveryItem) { connStr := event.GetProtocolCode() + "://" + event.GetTransportUrl().Host log.Info().Str("connection string", connStr).Msg("Found KNX Gateway") @@ -62,19 +60,18 @@ func main() { connectionStrings = append(connectionStrings, connStr) } + var closes []func() error for _, connStr := range connectionStrings { log.Info().Str("connection string", connStr).Msg("Connecting") - crc := driverManager.GetConnection(connStr) + connection, err := driverManager.GetConnection(ctx, connStr) // Wait for the driver to connect (or not) - connectionResult := <-crc - if connectionResult.GetErr() != nil { - log.Error().Err(connectionResult.GetErr()).Msg("error connecting to PLC") + if err != nil { + log.Error().Err(err).Msg("error connecting to PLC") return } log.Info().Str("connection string", connStr).Msg("Connected") - connection := connectionResult.GetConnection() - connection.BlockingClose() + closes = append(closes, connection.Close) // Try to find all KNX devices on the current network browseRequest, err := connection.BrowseRequestBuilder(). @@ -86,7 +83,7 @@ func main() { log.Error().Err(err).Msg("error creating browse request") return } - brr := browseRequest.ExecuteWithInterceptor(func(result apiModel.PlcBrowseItem) bool { + brr := browseRequest.ExecuteWithInterceptor(ctx, func(result apiModel.PlcBrowseItem) bool { knxTag := result.GetTag() knxAddress := knxTag.GetAddressString() log.Info().Str("knxAddress", knxAddress).Msg("Inspecting detected Device at KNX Address") @@ -99,7 +96,7 @@ func main() { log.Error().Err(err).Msg("error creating read request") return false } - brr := browseRequest.Execute() + brr := browseRequest.Execute(ctx) browseResult := <-brr if browseResult.GetErr() != nil { log.Error().Err(browseResult.GetErr()).Msg("error executing the browse request for com-objects") @@ -138,7 +135,7 @@ func main() { return false } - rrr := readRequest.Execute() + rrr := readRequest.Execute(ctx) readRequestResult := <-rrr if readRequestResult.GetErr() != nil { @@ -201,4 +198,7 @@ func main() { } return } + for _, closer := range closes { + _ = closer() + } } diff --git a/plc4go/examples/knx/groupAddressRead/hello_world_plc4go_knx_read_group_address.go b/plc4go/examples/knx/groupAddressRead/hello_world_plc4go_knx_read_group_address.go index 9572ed529..a70d14e5a 100644 --- a/plc4go/examples/knx/groupAddressRead/hello_world_plc4go_knx_read_group_address.go +++ b/plc4go/examples/knx/groupAddressRead/hello_world_plc4go_knx_read_group_address.go @@ -20,17 +20,16 @@ package main import ( + "context" "fmt" "github.com/apache/plc4x/plc4go/pkg/api" "github.com/apache/plc4x/plc4go/pkg/api/drivers" - "github.com/apache/plc4x/plc4go/pkg/api/logging" apiModel "github.com/apache/plc4x/plc4go/pkg/api/model" ) func main() { - // Set logging to INFO - logging.InfoLevel() + ctx := context.Background() driverManager := plc4go.NewPlcDriverManager() defer func() { @@ -41,18 +40,16 @@ func main() { drivers.RegisterKnxDriver(driverManager) // Get a connection to a remote PLC - crc := driverManager.GetConnection("knxnet-ip:udp://192.168.42.11") + connection, err := driverManager.GetConnection(ctx, "knxnet-ip:udp://192.168.42.11") // Wait for the driver to connect (or not) - connectionResult := <-crc - if connectionResult.GetErr() != nil { - fmt.Printf("error connecting to PLC: %s", connectionResult.GetErr().Error()) + if err != nil { + fmt.Printf("error connecting to PLC: %s", err.Error()) return } - connection := connectionResult.GetConnection() // Make sure the connection is closed at the end - defer connection.BlockingClose() + defer connection.Close() // Prepare a read-request readRequest, err := connection.ReadRequestBuilder(). @@ -60,12 +57,12 @@ func main() { AddTagAddress("secondFlorTemperatures", "3/[2,3,4,6]/10:DPT_Value_Temp"). Build() if err != nil { - fmt.Printf("error preparing read-request: %s", connectionResult.GetErr().Error()) + fmt.Printf("error preparing read-request: %s", err.Error()) return } // Execute a read-request - rrc := readRequest.Execute() + rrc := readRequest.Execute(ctx) // Wait for the response to finish rrr := <-rrc diff --git a/plc4go/examples/knx/subscribe/hello_world_plc4go_knx_subscription.go b/plc4go/examples/knx/subscribe/hello_world_plc4go_knx_subscription.go index 8a0dafe7b..a0eb4feb0 100644 --- a/plc4go/examples/knx/subscribe/hello_world_plc4go_knx_subscription.go +++ b/plc4go/examples/knx/subscribe/hello_world_plc4go_knx_subscription.go @@ -20,21 +20,19 @@ package main import ( + "context" "fmt" "strings" "time" "github.com/apache/plc4x/plc4go/pkg/api" "github.com/apache/plc4x/plc4go/pkg/api/drivers" - "github.com/apache/plc4x/plc4go/pkg/api/logging" apiModel "github.com/apache/plc4x/plc4go/pkg/api/model" apiValues "github.com/apache/plc4x/plc4go/pkg/api/values" ) func main() { - // Set logging to INFO - logging.InfoLevel() - + ctx := context.Background() driverManager := plc4go.NewPlcDriverManager() defer func() { if err := driverManager.Close(); err != nil { @@ -44,21 +42,20 @@ func main() { drivers.RegisterKnxDriver(driverManager) // Get a connection to a remote PLC - crc := driverManager.GetConnection("knxnet-ip:udp://192.168.42.11") + connection, err := driverManager.GetConnection(ctx, "knxnet-ip:udp://192.168.42.11") // Wait for the driver to connect (or not) - connectionResult := <-crc - if connectionResult.GetErr() != nil { - fmt.Printf("error connecting to PLC: %s", connectionResult.GetErr().Error()) + if err != nil { + fmt.Printf("error connecting to PLC: %s", err.Error()) return } - connection := connectionResult.GetConnection() // Make sure the connection is closed at the end - defer connection.BlockingClose() + defer connection.Close() // Prepare a subscription-request - if subscriptionRequest, err := connection.SubscriptionRequestBuilder(). + if subscriptionRequest, err := connection. + SubscriptionRequestBuilder(). // Intentionally catching all without datatype and the temperature apiValues of the first floor with type AddChangeOfStateTagAddress("all", "*/*/*"). AddChangeOfStateTagAddress("firstFlorTemperatures", "2/[1,2,4,6]/10:DPT_Value_Temp"). @@ -96,9 +93,10 @@ func main() { } } } - }).Build(); err == nil { + }). + Build(); err == nil { // Execute a subscription-request - rrc := subscriptionRequest.Execute() + rrc := subscriptionRequest.Execute(ctx) // Wait for the response to finish rrr := <-rrc @@ -117,7 +115,7 @@ func main() { time.Sleep(time.Minute * 5) } else { - fmt.Printf("error preparing subscription-request: %s", connectionResult.GetErr().Error()) + fmt.Printf("error preparing subscription-request: %s", err.Error()) return } } diff --git a/plc4go/examples/read/hello_world_plc4go_read.go b/plc4go/examples/read/hello_world_plc4go_read.go index dfe89e496..a41cdbc06 100644 --- a/plc4go/examples/read/hello_world_plc4go_read.go +++ b/plc4go/examples/read/hello_world_plc4go_read.go @@ -20,6 +20,7 @@ package main import ( + "context" "fmt" "github.com/apache/plc4x/plc4go/pkg/api" @@ -28,6 +29,7 @@ import ( ) func main() { + ctx := context.Background() driverManager := plc4go.NewPlcDriverManager() defer func() { if err := driverManager.Close(); err != nil { @@ -37,30 +39,28 @@ func main() { drivers.RegisterModbusTcpDriver(driverManager) // Get a connection to a remote PLC - crc := driverManager.GetConnection("modbus-tcp://192.168.23.30") + connection, err := driverManager.GetConnection(ctx, "modbus-tcp://192.168.23.30") // Wait for the driver to connect (or not) - connectionResult := <-crc - if connectionResult.GetErr() != nil { - fmt.Printf("error connecting to PLC: %s", connectionResult.GetErr().Error()) + if err != nil { + fmt.Printf("error connecting to PLC: %s", err.Error()) return } - connection := connectionResult.GetConnection() // Make sure the connection is closed at the end - defer connection.BlockingClose() + defer connection.Close() // Prepare a read-request readRequest, err := connection.ReadRequestBuilder(). AddTagAddress("tag", "holding-register:26:REAL"). Build() if err != nil { - fmt.Printf("error preparing read-request: %s", connectionResult.GetErr().Error()) + fmt.Printf("error preparing read-request: %s", err.Error()) return } // Execute a read-request - rrc := readRequest.Execute() + rrc := readRequest.Execute(ctx) // Wait for the response to finish rrr := <-rrc diff --git a/plc4go/examples/write/hello_world_plc4go_write.go b/plc4go/examples/write/hello_world_plc4go_write.go index 6f5765c82..fd1ff06b3 100644 --- a/plc4go/examples/write/hello_world_plc4go_write.go +++ b/plc4go/examples/write/hello_world_plc4go_write.go @@ -20,6 +20,7 @@ package main import ( + "context" "fmt" "github.com/apache/plc4x/plc4go/pkg/api" @@ -28,6 +29,7 @@ import ( ) func main() { + ctx := context.Background() driverManager := plc4go.NewPlcDriverManager() defer func() { if err := driverManager.Close(); err != nil { @@ -37,30 +39,28 @@ func main() { drivers.RegisterModbusTcpDriver(driverManager) // Get a connection to a remote PLC - crc := driverManager.GetConnection("modbus-tcp://192.168.23.30") + connection, err := driverManager.GetConnection(ctx, "modbus-tcp://192.168.23.30") // Wait for the driver to connect (or not) - connectionResult := <-crc - if connectionResult.GetErr() != nil { - fmt.Printf("error connecting to PLC: %s", connectionResult.GetErr().Error()) + if err != nil { + fmt.Printf("error connecting to PLC: %s", err.Error()) return } - connection := connectionResult.GetConnection() // Make sure the connection is closed at the end - defer connection.BlockingClose() + defer connection.Close() // Prepare a write-request writeRequest, err := connection.WriteRequestBuilder(). AddTagAddress("tag", "holding-register:26:REAL", 2.7182818284). Build() if err != nil { - fmt.Printf("error preparing read-request: %s", connectionResult.GetErr().Error()) + fmt.Printf("error preparing read-request: %s", err.Error()) return } // Execute a read-request - wrc := writeRequest.Execute() + wrc := writeRequest.Execute(ctx) // Wait for the response to finish wrr := <-wrc diff --git a/plc4go/go.mod b/plc4go/go.mod index d25cd146d..d4d5ac792 100644 --- a/plc4go/go.mod +++ b/plc4go/go.mod @@ -17,62 +17,69 @@ // under the License. // -module github.com/apache/plc4x/plc4go-extras +module github.com/apache/plc4x-extras/plc4go -go 1.22 - -toolchain go1.22.5 +go 1.27 require ( - github.com/apache/plc4x/plc4go v0.0.0-20240726114050-0f1b34fecae4 - github.com/fatih/color v1.17.0 - github.com/gdamore/tcell/v2 v2.7.4 - github.com/gopacket/gopacket v1.2.0 - github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 + charm.land/bubbles/v2 v2.2.1 + charm.land/bubbletea/v2 v2.0.9 + charm.land/lipgloss/v2 v2.0.6 + github.com/apache/plc4x/plc4go v0.0.0-20260907131220-8ed193b9438b + github.com/charmbracelet/fang v1.0.0 + github.com/charmbracelet/x/exp/teatest/v2 v2.0.0-20260902165432-6f6ad8b37b0a + github.com/gopacket/gopacket v1.7.2 github.com/pkg/errors v0.9.1 - github.com/rivo/tview v0.0.0-20230208211350-7dfff1ce7854 - github.com/rs/zerolog v1.33.0 - github.com/schollz/progressbar/v3 v3.14.5 - github.com/spf13/cobra v1.8.1 - github.com/spf13/viper v1.19.0 - github.com/stretchr/testify v1.9.0 + github.com/rs/zerolog v1.35.1 + github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 + github.com/stretchr/testify v1.12.1 + golang.org/x/term v0.46.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/IBM/netaddr v1.5.0 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/gdamore/encoding v1.0.0 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect - github.com/icza/bitio v1.1.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-udiff v0.4.1 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect + github.com/charmbracelet/x/ansi v0.11.8 // indirect + github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect + github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/fatih/color v1.19.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/jacobsa/go-serial v0.0.0-20180131005756-15cf729a72d4 // indirect - github.com/libp2p/go-reuseport v0.4.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/magiconair/properties v1.8.7 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.15 // indirect - github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/pelletier/go-toml/v2 v2.2.2 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/lucasb-eyer/go-colorful v1.4.1 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/mattn/go-runewidth v0.0.27 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/mango v0.1.0 // indirect + github.com/muesli/mango-cobra v1.2.0 // indirect + github.com/muesli/mango-pflag v0.1.0 // indirect + github.com/muesli/roff v0.1.0 // indirect + github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/sagikazarmark/locafero v0.4.0 // indirect - github.com/sagikazarmark/slog-shim v0.1.0 // indirect - github.com/sourcegraph/conc v0.3.0 // indirect - github.com/spf13/afero v1.11.0 // indirect - github.com/spf13/cast v1.6.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect - github.com/stretchr/objx v0.5.2 // indirect + github.com/rogpeppe/go-internal v1.11.0 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/stretchr/objx v0.5.3 // indirect github.com/subosito/gotenv v1.6.0 // indirect - go.uber.org/atomic v1.9.0 // indirect - go.uber.org/multierr v1.9.0 // indirect - golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect - golang.org/x/sys v0.22.0 // indirect - golang.org/x/term v0.22.0 // indirect - golang.org/x/text v0.16.0 // indirect - gopkg.in/ini.v1 v1.67.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.41.0 // indirect + gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect ) diff --git a/plc4go/go.sum b/plc4go/go.sum index 9e98dbd01..86b5454e0 100644 --- a/plc4go/go.sum +++ b/plc4go/go.sum @@ -1,176 +1,135 @@ -github.com/IBM/netaddr v1.5.0 h1:IJlFZe1+nFs09TeMB/HOP4+xBnX2iM/xgiDOgZgTJq0= -github.com/IBM/netaddr v1.5.0/go.mod h1:DDBPeYgbFzoXHjSz9Jwk7K8wmWV4+a/Kv0LqRnb8we4= +charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU= +charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc= +charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8= +charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss= +charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= +charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= github.com/ajankovic/xdiff v0.0.1 h1:V1cj8t5xwYzm6ZGPqPOlAc9AIajXuTEn41D/1MJBWMM= github.com/ajankovic/xdiff v0.0.1/go.mod h1:SUmEZ67uB97I0zkiuQ+lb+LOms9ipn8X+p+2RdJV710= -github.com/antchfx/xpath v0.0.0-20170515025933-1f3266e77307 h1:C735MoY/X+UOx6SECmHk5pVOj51h839Ph13pEoY8UmU= -github.com/antchfx/xpath v0.0.0-20170515025933-1f3266e77307/go.mod h1:Yee4kTMuNiPYJ7nSNorELQMr1J33uOpXDMByNYhvtNk= -github.com/apache/plc4x/plc4go v0.0.0-20240726114050-0f1b34fecae4 h1:HHE39HiOi0Y6We7Diql4dLZUVQRSWFQBGxpTVOpbYKY= -github.com/apache/plc4x/plc4go v0.0.0-20240726114050-0f1b34fecae4/go.mod h1:fbk1wNILU9oNLtPxRmHUmQT/+5ySuvAFkmi3SrBMb10= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= -github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= +github.com/apache/plc4x/plc4go v0.0.0-20260907131220-8ed193b9438b h1:wDfhQwN2ElvP9lsEdisfdChMduL2ilh7J+aDLgQJjIE= +github.com/apache/plc4x/plc4go v0.0.0-20260907131220-8ed193b9438b/go.mod h1:eXckuqFuXTmT05hN7BrZUIthBY96xSoXMXJNt//fHXo= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/fang v1.0.0 h1:jESBY40agJOlLYnnv9jE0mLqDGTxEk0hkOnx7YGyRlQ= +github.com/charmbracelet/fang v1.0.0/go.mod h1:P5/DNb9DddQ0Z0dbc0P3ol4/ix5Po7Ofr2KMBfAqoCo= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro= +github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= +github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= +github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f h1:8CnFOYzrMArVN42jYaGvnBo3mxdONgt09fly+9B96GY= +github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f/go.mod h1:V8n/g3qVKNxr2FR37Y+otCsMySvZr601T0C7coEP0bw= +github.com/charmbracelet/x/exp/teatest/v2 v2.0.0-20260902165432-6f6ad8b37b0a h1:7rIWhMhA7nUZAgAi+MVixH9oXst25gMHFJk5fsLEAcw= +github.com/charmbracelet/x/exp/teatest/v2 v2.0.0-20260902165432-6f6ad8b37b0a/go.mod h1:aRoQwQWmN9LBG2xi3sVByMFt2fdkPCagd0GAJ1qwOfw= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= -github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= -github.com/gdamore/tcell/v2 v2.7.4 h1:sg6/UnTM9jGpZU+oFYAsDahfchWAFW8Xx2yFinNSAYU= -github.com/gdamore/tcell/v2 v2.7.4/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gopacket/gopacket v1.2.0 h1:eXbzFad7f73P1n2EJHQlsKuvIMJjVXK5tXoSca78I3A= -github.com/gopacket/gopacket v1.2.0/go.mod h1:BrAKEy5EOGQ76LSqh7DMAr7z0NNPdczWm2GxCG7+I8M= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0= -github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= -github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k= -github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/gopacket/gopacket v1.7.2 h1:ttSVNW9A3eUFaSd9+D95aD03Knk2j7KfajhN5twYSHo= +github.com/gopacket/gopacket v1.7.2/go.mod h1:QKowPlTLrQU2rqV5C5I14Aoaid3l8da3kbddibc/Wgk= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jacobsa/go-serial v0.0.0-20180131005756-15cf729a72d4 h1:G2ztCwXov8mRvP0ZfjE6nAlaCX2XbykaeHdbT6KwDz0= -github.com/jacobsa/go-serial v0.0.0-20180131005756-15cf729a72d4/go.mod h1:2RvX5ZjVtsznNZPEt4xwJXNJrM3VTZoQf7V6gk0ysvs= -github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 h1:qGQQKEcAR99REcMpsXCp3lJ03zYT1PkRd3kQGPn9GVg= -github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/libp2p/go-reuseport v0.4.0 h1:nR5KU7hD0WxXCJbmw7r2rhRYruNRl2koHw8fQscQm2s= -github.com/libp2p/go-reuseport v0.4.0/go.mod h1:ZtI03j/wO5hZVDFo2jKywN6bYKWLOy8Se6DrI2E1cLU= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= -github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= -github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= +github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0= +github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= +github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= +github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg= +github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA= +github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= +github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= +github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= +github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/tview v0.0.0-20230208211350-7dfff1ce7854 h1:/IIOjnKLbuO5YtZUZaJVw9fc062ChPlaGWEBmJ6jyGY= -github.com/rivo/tview v0.0.0-20230208211350-7dfff1ce7854/go.mod h1:lBUy/T5kyMudFzWUH/C2moN+NlU5qF505vzOyINXuUQ= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= -github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= -github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= -github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= -github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= -github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= -github.com/schollz/progressbar/v3 v3.14.5 h1:97RrSxbBASxQuZN9yemnyGrFZ/swnG6IrEe2R0BseX8= -github.com/schollz/progressbar/v3 v3.14.5/go.mod h1:Nrzpuw3Nl0srLY0VlTvC4V6RL50pcEymjy6qyJAaLa0= -github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= -github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= -github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= -github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= -github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= -github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/subchen/go-xmldom v1.1.2 h1:7evI2YqfYYOnuj+PBwyaOZZYjl3iWq35P6KfBUw9jeU= -github.com/subchen/go-xmldom v1.1.2/go.mod h1:6Pg/HuX5/T4Jlj0IPJF1sRxKVoI/rrKP6LIMge9d5/8= +github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= +github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= -go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= -go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= -golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= -golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +github.com/vishvananda/netlink v1.1.0 h1:1iyaYNBLmP6L0220aDnYQpo1QEV4t4hJ+xEEhhJH8j0= +github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE= +github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74 h1:gga7acRE695APm9hlsSMoOoE65U4/TcqNj90mc69Rlg= +github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs= +golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= +golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= -gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plc4go/pom.xml b/plc4go/pom.xml index 4145770b4..3e94c1dd5 100644 --- a/plc4go/pom.xml +++ b/plc4go/pom.xml @@ -17,24 +17,22 @@ specific language governing permissions and limitations under the License. --> - - - 4.0.0 + - org.apache.plc4x + org.apache.plc4x.extras plc4x-extras-parent - 0.13.0-SNAPSHOT + 1.0.0-SNAPSHOT plc4go-extras - mvn-golang + pom PLC4Go-Extras Examples, integration modules and tools for Go. - 2024-02-16T14:53:02Z + 2026-09-20T15:22:10Z . ** @@ -55,10 +53,7 @@ -coverprofile=target/coverage.out - v0.20.0 - v2.0.1 - v1.11.0 - v2.42.2 + 1.27.0 @@ -88,58 +83,22 @@ com.igormaznitsa - mvn-golang-wrapper - 2.3.10 - true + gosdk-wrapper-maven-plugin + 1.1.2 - - - get-stringer - initialize - - install - - - - golang.org/x/tools/cmd/stringer@${go.stringer.version} - - - - - get-go-junit-report - initialize - - install - - - - - github.com/sruehl/go-junit-report/v2@${go.junit-report.version} - - - - - get-gotestsum - initialize - - install - - - - gotest.tools/gotestsum@${go.gotestsum.version} - - - + - get-mockery - initialize + default-generate + generate-sources - install + execute - - github.com/vektra/mockery/v2@${go.mockery.version} - + + generate + + ./... + @@ -148,54 +107,32 @@ generate-mocks process-sources - custom + execute - mockery - --all - - --with-expecter - + + tool + -modfile=tools.mod + mockery + --log-level + info + - - - - default-fix - process-sources - - fix - - - default-fmt process-sources - fmt + execute - + + fmt - ./... - - - - - - - default-test - test - - test - - - true + ./... + @@ -203,27 +140,39 @@ test test - custom + execute ${skipTests} - gotestsum - --junitfile - - target/surefire-reports/go-junit-report.xml - -- - ${go.cover.flag} - - - ./... - + + tool + -modfile=tools.mod + gotestsum + --junitfile + target/surefire-reports/go-junit-report.xml + -- + ${go.cover.flag} + ./... + + - default-build + fix-imports-internal + process-sources + + execute + - true + + tool + -modfile=tools.mod + goimports-reviser + -excludes + .idea,.git + ./... + @@ -231,21 +180,78 @@ run-go-vet verify - vet + execute + + + vet + + - - ${project.basedir} - true - 1.22.0 + ${go.version} + ${project.basedir} + GOSDK_SITE + + + org.apache.maven.plugins + maven-clean-plugin + + + default-clean + none + + + + + org.apache.maven.plugins + maven-jar-plugin + + + default-jar + none + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + default-test + none + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + default-compile + none + + + default-testCompile + none + + + + + org.apache.maven.plugins + maven-install-plugin + + + default-install + none + + + + org.apache.maven.plugins @@ -256,39 +262,4 @@ - - - - .os-windows-x86_64 - - - windows - x86_64 - - - - - -v - - - - .os-windows-aarch64 - - - windows - aarch64 - - - - - -v - - - \ No newline at end of file diff --git a/plc4go/tools.mod b/plc4go/tools.mod new file mode 100644 index 000000000..3519543c8 --- /dev/null +++ b/plc4go/tools.mod @@ -0,0 +1,111 @@ +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you 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. +// + +// tools.mod holds the dev/CI tools invoked from the build so their transitive +// dependencies stay out of go.mod. Use it via `go tool -modfile=tools.mod `. +// +// Unlike plc4x, this module currently has no //go:generate directives, so no tool +// needs to stay in go.mod. If one is added, move that tool back to go.mod: `go +// generate` does not accept -modfile, so a generate directive cannot reach a tool +// declared here. + +module github.com/apache/plc4x-extras/plc4go + +go 1.27 + +require ( + charm.land/lipgloss/v2 v2.0.6 + github.com/apache/plc4x/plc4go v0.0.0-20260902093211-931674134ff2 + github.com/fatih/color v1.19.0 + github.com/gdamore/tcell/v2 v2.13.10 + github.com/gopacket/gopacket v1.7.1 + github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 + github.com/rivo/tview v0.42.0 + github.com/rs/zerolog v1.35.1 + github.com/schollz/progressbar/v3 v3.19.1 + github.com/spf13/cobra v1.8.1 + github.com/spf13/viper v1.19.0 + github.com/stretchr/testify v1.12.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/bitfield/gotestdox v0.2.2 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect + github.com/charmbracelet/x/ansi v0.11.8 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/chigopher/pathlib v0.19.1 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/dnephin/pflag v1.0.7 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gdamore/encoding v1.0.1 // indirect + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/huandu/xstrings v1.4.0 // indirect + github.com/iancoleman/strcase v0.3.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/incu6us/goimports-reviser/v3 v3.10.0 // indirect + github.com/jinzhu/copier v0.4.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.1 // indirect + github.com/magiconair/properties v1.8.9 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/mattn/go-runewidth v0.0.24 // indirect + github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sagikazarmark/locafero v0.7.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.12.0 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/stretchr/objx v0.5.3 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/vektra/mockery/v2 v2.53.2 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect + golang.org/x/mod v0.39.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect + golang.org/x/text v0.41.0 // indirect + golang.org/x/tools v0.49.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gotest.tools/gotestsum v1.13.0 // indirect +) + +tool golang.org/x/tools/cmd/stringer + +tool gotest.tools/gotestsum + +tool github.com/vektra/mockery/v2 + +tool github.com/incu6us/goimports-reviser/v3 diff --git a/plc4go/tools.sum b/plc4go/tools.sum new file mode 100644 index 000000000..5f0514021 --- /dev/null +++ b/plc4go/tools.sum @@ -0,0 +1,196 @@ +charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= +charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= +github.com/ajankovic/xdiff v0.0.1 h1:V1cj8t5xwYzm6ZGPqPOlAc9AIajXuTEn41D/1MJBWMM= +github.com/ajankovic/xdiff v0.0.1/go.mod h1:SUmEZ67uB97I0zkiuQ+lb+LOms9ipn8X+p+2RdJV710= +github.com/apache/plc4x/plc4go v0.0.0-20260902093211-931674134ff2 h1:AehpEo6hGU1fFryP9ZQtkwhiQom9jqbgB1Lhp1CBfjs= +github.com/apache/plc4x/plc4go v0.0.0-20260902093211-931674134ff2/go.mod h1:eXckuqFuXTmT05hN7BrZUIthBY96xSoXMXJNt//fHXo= +github.com/bitfield/gotestdox v0.2.2 h1:x6RcPAbBbErKLnapz1QeAlf3ospg8efBsedU93CDsnE= +github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8eqRJ3N+9pY= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA= +github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro= +github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= +github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= +github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= +github.com/chigopher/pathlib v0.19.1 h1:RoLlUJc0CqBGwq239cilyhxPNLXTK+HXoASGyGznx5A= +github.com/chigopher/pathlib v0.19.1/go.mod h1:tzC1dZLW8o33UQpWkNkhvPwL5n4yyFRFm/jL1YGWFvY= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk= +github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= +github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= +github.com/gdamore/tcell/v2 v2.13.10 h1:Afs3JKt83HnhuUKdZ3MnxUgOqQRWftj5JyDqv1LLynA= +github.com/gdamore/tcell/v2 v2.13.10/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/gopacket/gopacket v1.7.1 h1:1C7/wrJ5HyiEAYDtStJHQk4rV0ChpanZDV9+3Ov3gaM= +github.com/gopacket/gopacket v1.7.1/go.mod h1:QKowPlTLrQU2rqV5C5I14Aoaid3l8da3kbddibc/Wgk= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/huandu/xstrings v1.4.0 h1:D17IlohoQq4UcpqD7fDk80P7l+lwAmlFaBHgOipl2FU= +github.com/huandu/xstrings v1.4.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= +github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/incu6us/goimports-reviser/v3 v3.10.0 h1:hhN6UcrkjK6gLpPAuqGqMd5evW8ZNMD5obQq1OsENIg= +github.com/incu6us/goimports-reviser/v3 v3.10.0/go.mod h1:5ORZPzRojNeWys/8rPHJovL44cV0bovZK0S3THxVIV4= +github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= +github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= +github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 h1:qGQQKEcAR99REcMpsXCp3lJ03zYT1PkRd3kQGPn9GVg= +github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= +github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM= +github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= +github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= +github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/schollz/progressbar/v3 v3.19.1 h1:iv8BgwOvdML/S3p84uBpy/IMigv4U9594vPZYa2EdrU= +github.com/schollz/progressbar/v3 v3.19.1/go.mod h1:LFL7jqimKxfhero4K1eCkUr/6R39AgQeiPCJtlTWIW8= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= +github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/vektra/mockery/v2 v2.53.2 h1:4G/4fl9x722Yb8hLqH1YU3XZNRJFwl5KUMvpkmAyuC8= +github.com/vektra/mockery/v2 v2.53.2/go.mod h1:UJT+mgXhCcOCHXTnM5cJHCZL+d76BYB+EbY1sFztEB8= +github.com/vishvananda/netlink v1.1.0 h1:1iyaYNBLmP6L0220aDnYQpo1QEV4t4hJ+xEEhhJH8j0= +github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE= +github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74 h1:gga7acRE695APm9hlsSMoOoE65U4/TcqNj90mc69Rlg= +github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs= +golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74= +golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/gotestsum v1.13.0 h1:+Lh454O9mu9AMG1APV4o0y7oDYKyik/3kBOiCqiEpRo= +gotest.tools/gotestsum v1.13.0/go.mod h1:7f0NS5hFb0dWr4NtcsAsF0y1kzjEFfAil0HiBQJE03Q= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/plc4go/tools/QUICKSTART.md b/plc4go/tools/QUICKSTART.md new file mode 100644 index 000000000..5cc73a9e0 --- /dev/null +++ b/plc4go/tools/QUICKSTART.md @@ -0,0 +1,276 @@ + + +# Quickstart + +Both tools run against simulated devices with `--demo`, so neither needs hardware to try. +Everything below works on a fresh checkout with no configuration. + +```bash +cd plc4go +go run ./tools/plc4xbrowser --demo +go run ./tools/plc4xpcapanalyzer ui --demo + +# and without a terminal interface, for a report you can hand to something else +go run ./tools/plc4xpcapanalyzer analyze --demo --report report.xml +``` + +`Ctrl+C` stops whatever is running. With nothing running it offers to quit, and `y` or a second +`Ctrl+C` confirms — any other key keeps the session. Nothing ends a session that is holding open +connections or a capture on a single keystroke. + +## plc4xbrowser --demo + +Opens already connected to `demo://plant-1`, with a `DEMO` badge in the status bar so +simulated values can never be mistaken for readings from real hardware. + +Values are coloured by data type: numbers cyan, flags amber, text lime, times violet, and a +type the tool does not recognise stays muted rather than being given a colour that would imply +it had been categorised. `OK` is green and a failure red, wherever either appears. + +### 1. See what the device exposes + +``` +browse-direct demo://plant-1 +``` + +Eight tags appear in **Messages**. Select one to see it in **Detail** — click it, or press +`2` and use `↑`/`↓`. + +### 2. Read a tag + +``` +read-direct demo://plant-1 temp/1 +``` + +Run it a few times. The value moves: the simulator shapes each tag as a sine with a period of +17 readings, so values drift the way a plant reading drifts rather than jumping about. They are +also a pure function of a per-tag counter, so two runs of the demo show the same sequence. + +`counter/1` is the one tag that advances on every single read. + +### 3. Compose a multi-tag request + +`read`, `write`, `browse` and `subscribe` open a form, because a real request has several tags +and a single line cannot express one: + +``` +read demo://plant-1 +``` + +| Key | Effect | +| --- | --- | +| `Tab` / `Shift+Tab` | move between fields | +| `Ctrl+N` | add a tag row | +| `Ctrl+D` | remove the focused tag row (inside the form only) | +| `Enter` | run the request | +| `Esc` | cancel | + +Try submitting with the address left empty: the form refuses and says which tag needs one. A +request that fails at the device keeps the form open with the reason, so it can be corrected +rather than retyped. + +### 4. A write that sticks + +``` +write-direct demo://plant-1 motor/speed 1234 +read-direct demo://plant-1 motor/speed +``` + +The read returns `1234`. Writing a read-only tag is refused per tag: + +``` +write-direct demo://plant-1 temp/1 99 # ACCESS_DENIED +``` + +### 5. A live subscription + +``` +subscribe-direct demo://plant-1 temp/1 +``` + +New rows arrive once a second until the tool exits. + +### 6. Errors stay put + +``` +read-direct demo://nowhere temp/1 +``` + +The error is pinned above the prompt until `Esc` dismisses it, and it names the connections +that *are* open. Errors used to scroll away in a ten-row console. + +### 7. Act on what you found + +Browsing gives you **one** message carrying every tag, listed in **Detail** and kept in the +sidebar as a catalogue. Press `r`, `w` or `s` to read, write or subscribe to those tags — the +request is composed for you rather than retyped, and `Enter` runs it. `y` copies the selection. + +The keys work from either pane that shows the message: **Messages**, which holds the selection, +and **Detail**, which is where you actually read the tags and which draws the hint. + +### 8. Completion + +Type `re` then `Tab`. Type `read-direct ` then `Tab` and it offers the open connection. Type the +space after the connection and the tag catalogue appears straight away — a finished word is what +moves the completion on to the next argument, so no letter of the tag has to be typed first. + +### 9. Resize it + +Shrink the terminal below 100 columns: the prompt stays visible and the layout collapses to a +single column, with the sidebar and detail reachable as overlays. Below 60×12 it says the +terminal is too small rather than drawing something broken. + +## plc4xpcapanalyzer ui + +Opening a capture from the command line needs `-p`, because the interface starts on C-Bus: + +```bash +plc4xpcapanalyzer ui -p modbus-tcp capture.pcap +``` + +Without it the capture is analysed as C-Bus, which for any other protocol means a screenful of +parse failures that say nothing about either protocol. The protocol is also switchable in the +sidebar once the interface is open. + +## plc4xpcapanalyzer ui --demo + +Generates `cbus-demo.pcap` — ten packets of real C-Bus traffic, eight that round-trip +byte-identically through plc4x's own codec and two the codec will not accept — opens it, and +analyses it immediately. The file is removed on exit. + +### 1. Read the verdicts + +`2` focuses the packet pane. Eight packets read `ok`, and the last two do not: one is a +`parse` failure and one is a `skip`. + +The difference matters, and seeing both is the point of the demo. A parse failure is a defect: +the codec was handed a message and could not read it. A skip is not: the protocol itself says +the payload is not a whole message — a split transmission, an empty packet, an echo — so there +was nothing to check. Only the failure is a finding, which is why the **Findings** tab lists +one row rather than two. + +### 2. Look at the bytes + +Select a packet, `3` for **Detail**, then: + +| Key | View | +| --- | --- | +| `b` | raw bytes -- the offset and the text pane are dimmed, so the bytes are what you read | +| `t` | parsed tree -- plc4x draws it as nested boxes, dimmed so the field names stand out of them | +| `d` | diff (the default) | + +On a mismatch the diff shows the original against the reserialized bytes with the first +differing offset marked. That parse → reserialize → byte-compare loop is the tool's whole +purpose. + +### 3. Switch tabs + +`]` and `[` cycle **Packets → Log → Findings**. Findings lists only the defects, so for the +demo it holds the single parse failure and not the skip. + +The tool's own keys are bare letters, so they belong to a pane rather than to the prompt: press +`Shift+Tab` or a pane number first, or `]` is simply the character `]` in your command line. + +### 4. Re-run the analysis + +`a` re-analyses. Watch the phase breadcrumb (`index ▸ filter ▸ analyze`), the progress bar and +the live counters. `Esc` aborts a run in progress, and so does `Ctrl+C` — the results already +collected are kept. Once the abort is under way, `Ctrl+C` means the session again, so a run that +is slow to stop cannot trap you. + +### 5. Get a report out of it + +The command line writes a machine-readable report of what a run found: + +```bash +plc4xpcapanalyzer analyze c-bus -c --report report.xml +``` + +JUnit XML, or JSON if the name ends in `.json`. A defect is a failing test case carrying the +bytes and the offset the two first differ at; a skip is a skip. It is meant for CI: point it at +`target/surefire-reports/` and a codec regression shows up as a named failing test. + +C-Bus needs `-c`, because a request and a response are encoded differently. Without it most of +the capture is parsed the wrong way round and the report is full of failures that are not there. + +### 6. The command line still works + +``` +open # open another capture +analyze # analyse the open capture +extract # dump payloads +conf set # change an option +help # list every command +``` + +## Keys, both tools + +| Key | Effect | +| --- | --- | +| `Tab` | complete at the prompt | +| `Shift+Tab` | move between the prompt and the panes | +| `1` â€Ļ `4` | jump straight to a pane — the number is drawn in each pane's title | +| `Alt+1` â€Ļ `Alt+4` | the same, where the terminal delivers it | +| `:` or `Esc` | return the keyboard to the prompt | +| `↑` / `↓` or `k` / `j` | move within a pane; history at the prompt | +| `g` / `G` | first / last | +| `PgUp` / `PgDn` | scroll a screenful | +| `/` | filter the message list, applied as you type; `Esc` clears it | +| `f` | follow — keep the newest message selected (on by default) | +| `y` | copy the selection to the clipboard (works over ssh, via OSC52) | +| `e` | expand a long error | +| `L` | cycle the log level | +| `r` / `w` / `s` | read, write or subscribe to the selected message's tag | +| `b` | in Detail: the wire bytes of the request | +| `?` or `F1` | toggle full help | +| `Ctrl+C` | stop the command or run in flight; with nothing running, offer to quit | +| `Ctrl+D` | offer to quit, on an empty prompt — it deletes a character otherwise, as in a shell | +| `Esc` | also stops a command in flight, before it unwinds anything else | + +A bare letter is deliberately inert while the prompt has the keyboard: otherwise typing `quit` +would fire the `q`, `u`, `i` and `t` pane bindings. Digits are the exception: a digit can only +be a hotkey where it cannot be text, so it jumps from an *empty* prompt and from inside a pane, +and is text once a command is being typed. No command begins with a digit. + +`Alt`+digit is also bound, but do not rely on it: GNOME Terminal, Konsole and Windows Terminal +all bind `Alt`+digit to switching terminal tabs and never deliver it to the application. That is +why the bare digit exists, and why each pane draws its number in its title. + +**Mouse:** click a pane to focus it, click a row to select it, and use the wheel to scroll +whichever pane the pointer is over — the wheel does not move focus, so the log can be scrolled +without giving up the prompt. + +## Flags + +| Flag | Effect | +| --- | --- | +| `--demo` | run against simulated data, no hardware needed | +| `--ascii` | force ASCII drawing characters instead of Unicode | +| `--log-level` | `trace`, `debug`, `info`, `warn`, `error` | + +`NO_COLOR=1` in the environment strips colour from both tools -- including the shading behind +the top and bottom bars, which would otherwise print as solid blocks. The glyph set is otherwise +chosen from the locale, so a terminal without a UTF-8 locale gets the ASCII set automatically. + +## What demo mode cannot show + +Demo mode simulates the device, so nothing is put on a wire. The browser's byte view +(`b` in the Detail pane) therefore has no frames to show and says so. Connect to a real device +to see captured wire bytes there. diff --git a/plc4go/tools/README.md b/plc4go/tools/README.md new file mode 100644 index 000000000..d2f6019c4 --- /dev/null +++ b/plc4go/tools/README.md @@ -0,0 +1,478 @@ + + +# plc4go tools + +Two terminal tools built on [PLC4X](https://plc4x.apache.org/): + +- **plc4xbrowser** — connect to PLCs, browse their tags, and read, write and subscribe to them. +- **plc4xpcapanalyzer** — run captured protocol traffic back through plc4x's codecs and report + what fails to parse, fails to reserialize, or reserializes to different bytes. + +New here? Start with [QUICKSTART.md](QUICKSTART.md); both tools run against simulated devices +with `--demo` and need no hardware. + +## Building and running + +```bash +cd plc4go +go run ./tools/plc4xbrowser --demo +go run ./tools/plc4xpcapanalyzer ui --demo + +go build -o plc4xbrowser ./tools/plc4xbrowser +go build -o plc4xpcapanalyzer ./tools/plc4xpcapanalyzer +``` + +`plc4xpcapanalyzer` **requires cgo and libpcap**, because it reads captures through +`gopacket/pcap`. `CGO_ENABLED=0 go build ./tools/plc4xpcapanalyzer/...` fails. `plc4xbrowser` +has no such requirement and builds without cgo. + +## plc4xpcapanalyzer + +A pcap forensics tool. For each packet it feeds the application payload into a real plc4x codec, +re-serializes the parsed message and compares the bytes against the original. That loop is a +regression harness for the codecs themselves: a mismatch means plc4x's reader and writer +disagree about the same message. + +``` +plc4xpcapanalyzer analyze # parse, reserialize and compare +plc4xpcapanalyzer analyze bacnet # the same, per protocol +plc4xpcapanalyzer analyze cbus +plc4xpcapanalyzer extract # dump application payloads +plc4xpcapanalyzer ui [capture] # the terminal interface +``` + +The analyzer handles twelve protocols, and `plc4xpcapanalyzer analyze --help` lists them with +their aliases -- generated from the registry, so it cannot go stale the way a hand-written list +does. Every layer resolves names through that one registry, so the command line, the analyzer, +the extractor and the interface cannot disagree about them, and the names are the same driver +codes the browser uses: a user who connected with `modbus-tcp` does not have to learn a second +spelling to analyse its traffic. + +Ten of them are a codec plus a filter, held in `internal/codec`: the parse function, the byte +order its buffers use, and the BPF expression that selects its packets. Adding one is a data +change. BACnet and C-Bus stay outside it, because they need more -- C-Bus tracks request +context across a session, and BACnet has an adapter that predates this. + +Three of the twelve are serial protocols -- Modbus RTU, Modbus ASCII, Firmata -- so they only +reach a capture through a gateway, and analysing one means passing `--filter` for whichever port +that gateway uses. They are supported because the framing is the same wherever it is carried, +not because a serial line can be captured. + +Two protocols the browser drives are **not** analysable yet, and the reasons differ. **OPC UA** +has no wire-level PDU in its reference suite, so an adapter could be written but not verified +against anything; that one wants a real capture. **UMAS** needs a request-function key carried +from a request to its response -- stateful correlation, like C-Bus -- rather than a codec. +**IEC 60870-5-104** has only a driver test suite rather than a parser-serializer one, so its +vectors are in a different shape and want separate work. + +### How a packet's direction is decided + +Several of these protocols encode a request differently from a response, so the analyzer has to +know which way a packet went. Read the wrong way round, such a protocol is not read at all: +every response comes back as a parse failure. + +It is decided the way Wireshark decides it -- **by the port**. A packet leaving the protocol's +registered port came from the device; one arriving at it came from the client. That is a fact +about the packet, it holds per packet, and it asks the user nothing. + +`-c ` is the fallback, for a capture taken somewhere the protocol does not normally +live: a tunnel, a gateway, a non-standard port. When neither the port nor an address settles it, +the packet is read as a request -- what most of a capture is -- and the run says on stderr that +it guessed, naming the flag that would fix it. + +That notice goes to stderr rather than through the logger because the two have different +audiences: a log is for working out afterwards what happened, and this is for the person waiting +at the terminal. It began as a warning-level log line, and the default log level is `error`, so +it was invisible in exactly the situation it exists for. + +What is deliberately **not** done is parsing both ways and keeping whichever succeeds. A payload +can parse validly as both a request and a response, so that would silently pick one and report a +confident round trip for a message it had read wrongly. In a tool whose only output is whether +something round-trips, a plausible wrong answer is worse than a failure. Matching a response to +the request that preceded it would be the principled alternative, and needs state -- which is +what the C-Bus analyzer already does for its own protocol. + +### Why the byte order is in the codec + +Because it is not decoration. EtherNet/IP is little-endian, and parsed through the default +big-endian buffer **not one** of Apache's twenty-seven EtherNet/IP reference vectors survives +the round trip -- they fail at the first field. Wired up without it, the analyzer would have +reported that plc4x cannot parse its own protocol. ADS and SLMP declare little-endian too but +round-trip either way, because their generated code sets the order per field; EtherNet/IP's +expects it on the buffer. Nothing here is inferable from the protocol name, which is why each +codec states it and a test proves it. + +`--demo` needs no capture and no hardware: it generates a small C-Bus capture of ten packets, +eight of which round-trip, analyses that, and removes it afterwards. It works on `analyze`, +`extract` and the `c-bus` subcommand, and combines with `--report`, so a machine-readable report +can be produced on a machine that has never seen a capture: + +```bash +plc4xpcapanalyzer analyze --demo --report report.xml +``` + +It also supplies the client address, which matters more than it looks: C-Bus encodes a request +differently from a response, so without one most of the capture is read the wrong way round and +the same ten packets report six failures instead of one. An address given with `-c` still wins. +Asking for a protocol the demo does not speak is refused rather than obeyed -- analysing C-Bus +traffic as BACnet produces a screenful of failures that say nothing about either. + +`extract` prints payloads only at verbosity 2 or above: pass `-vv`. Without it the command walks +the capture and prints nothing, which looks like a failure and is not one. + +`analyze bacnet ` and `analyze c-bus ` reach the *subcommands*, which carry +their own filter and protocol-option flags. Reaching `analyze`'s own body takes a protocol name +that is not also a subcommand -- `analyze bacnetip ` -- and the two paths do the same +work. + +### Reports + +`--report ` writes what the run found in a form something other than a person can read: + +```bash +plc4xpcapanalyzer analyze c-bus capture.pcap -c 192.168.0.10 \ + --report target/surefire-reports/analyzer.xml +``` + +JUnit XML unless the name ends in `.json`. JUnit because it is what this repository already +speaks -- the Go tests run through `gotestsum --junitfile target/surefire-reports`, and +`go-platform-test-report.yml` renders that with `dorny/test-reporter` as `java-junit` -- so a +capture becomes a suite, a packet becomes a test case, and a codec that stops round-tripping a +message arrives in CI as a named failing test with the offending bytes and the first differing +offset attached, tracked across builds for free. + +A defect is a ``, typed `parse`, `serial` or `bytes`. A skip is a ``, because +a payload the protocol says is not a whole message is not a defect. A packet that never reached +a codec at all -- filtered out, or with no application layer -- is left out of the JUnit +document entirely: counting it would describe the capture rather than the codec and inflate a +total meant to say how much of plc4x was exercised. The JSON form keeps everything, since it is +not a test report and has no reason to hide anything. + +An interrupted run still writes its report, marked `aborted`. What was examined is evidence, and +discarding it would make `Ctrl+C` cost more than it saves. + +### Stopping a run + +`Ctrl+C` stops a run and reports `Aborted` rather than `Done`, keeping the counts gathered so +far. The interrupt handler is installed per command rather than on the root, deliberately: `ui` +runs a terminal interface that reads `Ctrl+C` as a key press and asks before it exits, and +cancelling its context from underneath would take that decision away. + +Both loops have always checked their context between packets. What was missing was any way to +reach the check: the entry points passed `context.TODO` and the root passes +`context.Background`, so the abort was implemented and unreachable, and a capture of any size +had exactly one way to stop -- for the process to be killed part way through a report. + +## plc4xbrowser + +An interactive client. Register a driver, connect, then browse, read, write or subscribe. + +``` +plc4xbrowser # against real devices +plc4xbrowser --demo # against simulated devices +``` + +Commands, as `help` lists them: + +| Command | Purpose | +| --- | --- | +| `discover ` | find devices on the network | +| `connect ` | open a connection | +| `disconnect ` | close one | +| `register ` | register a driver and its transports | +| `read` / `write` / `browse` / `subscribe` | open the request composer | +| `read-direct ` | one-shot single-tag read | +| `write-direct ` | one-shot single-tag write | +| `browse-direct [query]` | list the tags a connection exposes | +| `subscribe-direct ` | subscribe to one tag | +| `log level ` / `log clear` | logging | +| `history`, `clear`, `help`, `quit` | housekeeping | + +Configuration lives in the user config directory as YAML and remembers the last ten commands, +the last ten hosts, the log level and which drivers to auto-register. + +### Protocols + +Every public plc4go driver is available. The code is what the driver itself answers to, so it +is also what a connection string must use: + +| Code | Driver | Transport | Discovery | +| --- | --- | --- | --- | +| `ab-eth` | Allen Bradley ETH | tcp | | +| `ads` | Beckhoff TwinCat ADS | tcp | yes | +| `bacnet-ip` | BACnet/IP | udp | yes | +| `c-bus` | Clipsal Bus | tcp | | +| `eip` | EthernetIP | tcp | yes | +| `firmata` | Firmata | serial | | +| `iec-60870-5-104` | IEC 60870-5-104 | tcp | | +| `knxnet-ip` | KNXNet/IP | udp | yes | +| `logix` | Logix CIP | tcp | yes | +| `modbus-ascii` | Modbus ASCII | serial | | +| `modbus-rtu` | Modbus RTU | serial | | +| `modbus-tcp` | Modbus TCP | tcp | | +| `opcua` | Opcua | tcp | | +| `s7` | Siemens S7 (Basic) | tcp | | +| `slmp` | SLMP (MELSEC) 3E | tcp | | +| `umas` | UMAS (Schneider Electric) | tcp | | + +A test registers every one and asserts the driver answers to the code it is listed under, so a +name cannot drift out of step. `bacnetip` is accepted as an alias for `bacnet-ip`, because the +previous interface advertised that spelling and saved command histories still contain it. + +A serial connection names its port in the path rather than the host, since a serial connection +string has no host: + +``` +modbus-rtu:///dev/ttyUSB0 +firmata:///dev/ttyACM0 +``` + +### Configuration precedence + +The analyzer's settings come from three places, in the conventional order: a built-in default is +overridden by the persisted session configuration, and that is overridden by a flag on the +command line. `conf set` writes to the persisted layer, so it survives a restart without ever +beating a flag. + +Getting that order right needs one fact that is otherwise unrecoverable. The cobra flags bind +straight to the configuration singletons by pointer, so once a command line has been parsed +there is nothing left to distinguish a value the user asked for from the default that happens +to equal it. `config.SnapshotDefaults`, called from `Execute` after every registration and +before any parsing, records the defaults; a field that no longer holds its recorded default is +one a flag set. The session file is decoded into detached structs and then applied only to the +fields that still hold their defaults. + +Two limits, both deliberate. A flag passed with exactly its default value looks unset, so the +persisted value wins for that field -- the benign direction. And with no snapshot at all, which +in practice means a test rather than the tool, every non-zero value is treated as deliberate +and the persisted settings only fill in fields that are still zero. + +Before this, the file was decoded straight through the shared pointers, so it overwrote whatever +the command line had just put there: a value persisted months ago beat the flag typed a second +ago, silently and with no way to override it. + +### Stopping things + +`Ctrl+C` stops what is happening rather than ending the session. A user whose read is hanging on +an unreachable device, or who is watching an analysis grind through a large capture, reaches for +it to get back control -- and losing every open connection, or every record collected so far, is +not a reasonable answer to that keystroke. With nothing running it offers to quit instead; +`y`, `Enter` or a second `Ctrl+C` confirms, and any other key keeps the session and is swallowed +rather than also doing its usual job. `q` and `Ctrl+D` go through the same question. + +Once an abort is under way, `Ctrl+C` means the session again, so a run that is slow to stop +cannot trap the user in a tool that will not exit. + +The typed `quit` command still exits directly: four letters and a return are their own +confirmation. + +A cancelled context does not stop the goroutine that will eventually deliver the command's +outcome, so each run carries a sequence number and an outcome whose number no longer matches is +dropped. Without that, an abort was immediately followed by the aborted command's own error +appearing anyway. + +### Reading the screen + +Colour carries meaning rather than decoration, so a glance answers a question without reading: + +| What | How it reads | +| --- | --- | +| A value's data type | by family -- numbers cyan, flags amber, text lime, times violet | +| An unknown data type | muted, so it is visibly *uncategorised* rather than miscategorised | +| An outcome | `OK` green, a failure red, in the message list and in Detail alike | +| A log line | its level marker coloured: `ERR` red, `WRN` amber, `INF` accent, trace and debug muted | +| A count worth glancing at | the number in the accent, its label muted; a failure count red | +| The top and bottom bars | a raised band, framing the panes between them | + +In the analyzer specifically: + +| What | How it reads | +| --- | --- | +| A verdict | `ok` green, a defect red, and a skip or a filtered packet muted -- those are not findings and must not read as though they were | +| A direction | request and reply in different colours, so a exchange can be followed down the column rather than a row at a time | +| A hex dump | the offset and the text pane muted, the bytes at full contrast: only the middle column is the data | +| A parse tree | plc4x draws it as nested boxes; the frames drop to the chrome colour so the field names and values are the only thing at full contrast | + +Two implementation notes, because both have already caused bugs: + +A terminal cannot nest a background. An inner style's reset ends the outer background +mid-row, so a banded row is assembled from styles that each carry the band themselves -- +`Theme.OnSurface` and `Theme.Surfaced` exist for that, and every space in such a row lives +*inside* a styled run rather than beside one. A bare space between two styled pieces shows the +terminal's own background through the band and stripes the bar. + +For the same reason the analyzer builds its packet rows from styled cells rather than styling +the row, and `bubbles/table` gets a bare cell style. It styles each cell and then styles +the selected row around them, so any foreground on the cell style ends with a reset that wipes +the selection from every cell after the first, leaving the selected row indistinguishable. + +`NO_COLOR=1` strips all of it. Bold and reverse video stay: they are how a title and a cursor +still read without colour, and a background is the one thing that cannot degrade gracefully, +because it prints as a solid block. + +### Wire bytes + +With a real connection, the browser records the bytes crossing the transport, and the Detail +pane's `b` view shows the frames captured while the selected request was in flight. + +Two honest caveats. A frame is one transport read or write, **not** one protocol message: a +message may span several frames and one frame may carry several messages. And the association +between a request and its bytes is **temporal**, because a transport carries no request +identifier — on a connection that is also carrying a subscription, the window will include +unrelated traffic. The pane says both of these rather than implying otherwise. + +Demo mode records nothing, because a simulated device puts nothing on a wire. + +## Layout + +``` +tools/ + internal/ + tui/ theme, glyph sets, responsive layout, pane chrome, keymap, prompt + tuitest/ assertions about rendered output that both tools share + plcsession/ the seam between a UI and a PLC: Session, Live, Demo, frame capture + progress/ progress reporting, for a plain CLI and for a Bubble Tea UI alike + plc4xbrowser/ + browser/ command tree, root model, request composer, views, mouse + plc4xpcapanalyzer/ + cmd/ the cobra command line + config/ the CLI configuration singletons + internal/ + protocol/ the protocol-name registry + finding/ what an analysed packet turned out to be, and the one place that decides + report/ JUnit XML and JSON reports of what a run found + analyzer/ the parse, reserialize and compare loop + extractor/ payload extraction + pcaphandler/ libpcap access + codec/ the parse-and-reserialize pair for each protocol, with its byte order and filter + pcapfixture/ small deterministic captures from real protocol bytes + common/ shared packet types + ui/ the terminal interface +``` + +### One notion of a finding + +There are two implementations of the same analysis, and that is deliberate. `internal/analyzer` +walks a capture once; the terminal interface walks it in three phases so it can report progress +and stream records as they arrive. They cannot be collapsed without giving one of those two +things up. + +What they must not have is two ideas of what they found, and for a long time they did. The +analyzer classified a packet inline and reported the result only by logging it, so the interface +walked the capture again and classified it a second time -- reasonably, since bytes cannot be +recovered from a log line. Two implementations of one taxonomy drift, and these did: the +interface counted a skipped packet as normal traffic while the documentation called it a +failure, and no test could catch the disagreement because there was nothing shared for the two +to disagree with. + +`internal/finding` is now that shared thing. It owns the verdicts, the classification of a parse +error, the byte comparison and the offset of the first difference, the message name, and the +counting rule. `Record` in the interface embeds `finding.Finding` and adds only what a screen +needs -- the arrival offset, the direction, the parsed tree. The analyzer reports findings +through `Options.OnFinding`, which is what makes a report possible at all and what removed the +`TODO: write report to xml or something` that sat on each of its three failure counters. + +`TestBothAnalysisPathsAgree` runs one capture through both and compares verdict by verdict, +payload by payload, and counter by counter. That test is the point of the exercise: it is the +one thing that can catch the drift, and it could not have been written before. + +### The session seam + +`plcsession.Session` is the boundary between the browser's interface and a PLC. Nothing behind +it mentions a plc4x type, which is what makes three things possible at once: the interface can +be tested without a device, a network or a terminal; `--demo` is the same seam the tests +exercise rather than a parallel implementation that can drift; and a view can render a result +without knowing how it was produced. + +`Live` talks to real drivers. Every operation is bounded by a timeout, defaulting to ten +seconds, because an unbounded plc4x call waiting on a handshake blocks for sixty — long enough +to be indistinguishable from a hang. + +`Demo` answers instantly from a synthetic plant. Values are a pure function of a per-tag +counter rather than of the clock or a random source, so they visibly move while remaining +exactly reproducible. + +## Testing + +```bash +cd plc4go +go test ./tools/... +go test -race ./tools/... + +# what CI runs, via the maven build +go tool -modfile=tools.mod gotestsum -- ./... +``` + +Most view tests render with a colourless ASCII theme, so an assertion on the content is an +assertion on the content and a width is a count of characters. The `appearance_test.go` files +are the deliberate exception: the coloured themes are what a user actually sees, and a band that +stops a cell short of the edge, a style whose reset punches a hole in one, a selection that +vanishes after the first cell, and a background reaching a `NO_COLOR` terminal are all +invisible in plain text. `internal/tuitest` holds the escape-sequence assertions both tools +need, so there is one answer to what the terminal paints rather than two that can drift. + +Tests are deterministic: no sleeps as synchronisation, no network, no real terminal, and +injected clocks wherever time is observable. The terminal interfaces are driven by sending +typed messages to `Update` and asserting on the rendered output, which needs no terminal; +`teatest` covers the end-to-end path. + +Protocol tests use real bytes, and not bytes anyone here invented. `internal/pcapfixture` +carries the reference vectors from each protocol's own `ParserSerializerTestsuite` in the Apache +PLC4X repository -- ninety-four payloads across ten protocols -- which plc4x asserts round-trip: +parse the raw bytes, serialize the result, get the same bytes back. That is precisely the +property the analyzer measures, so a capture built from them is one the analyzer must report as +entirely clean, and `TestEveryProtocolRoundTripsItsOwnReferenceVectors` requires exactly that. +A failure there means either an adapter here is wrong or plc4x has regressed, which are the only +two things worth knowing. + +The direction of each fixture packet is the `response` parser argument its test case declares, +not a guess from its name: several protocols encode the two directions differently and the +analyzer derives the direction from the client address, so a packet on the wrong side of the +wire is read the wrong way round. The vectors were generated from those suites and committed, +so the tests stay hermetic rather than depending on a plc4x checkout beside this one. + +There are also sequences verified *not* to parse, so the failure counters can be exercised. Captures are written with the pure-Go `pcapgo` writer, so building a fixture needs no +cgo even though reading one back does, and with a fixed timestamp so they are byte-stable. + +## Development + +Tool dependencies live in `tools.mod`, keeping them out of `go.mod`: + +```bash +go tool -modfile=tools.mod gotestsum +go tool -modfile=tools.mod goimports-reviser -excludes .idea,.git ./... +go tool -modfile=tools.mod mockery +``` + +Note that `go generate` does not accept `-modfile`. This module has no `//go:generate` +directives today; if one is added, its tool has to move back to `go.mod`. + +Before committing: + +```bash +gofmt -l tools/ # must print nothing +go vet ./... +go fix -diff ./... # must print nothing: the 1.27 modernizers +go test -race ./... +``` + +Every source file needs the Apache licence header — `apache-rat` runs in the maven build and a +missing header fails the release. Generated checksum files (`go.sum`, `tools.sum`) and binary +test fixtures are excluded in the root `pom.xml`. diff --git a/plc4j/tools/ui/frontend/project/src/vite-env.d.ts b/plc4go/tools/doc.go similarity index 94% rename from plc4j/tools/ui/frontend/project/src/vite-env.d.ts rename to plc4go/tools/doc.go index bd74b6790..da1eca9d1 100644 --- a/plc4j/tools/ui/frontend/project/src/vite-env.d.ts +++ b/plc4go/tools/doc.go @@ -17,4 +17,5 @@ * under the License. */ -/// +// Package tools contains tools +package tools diff --git a/plc4go/tools/internal/plcsession/demo.go b/plc4go/tools/internal/plcsession/demo.go new file mode 100644 index 000000000..4c9ecd22b --- /dev/null +++ b/plc4go/tools/internal/plcsession/demo.go @@ -0,0 +1,474 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package plcsession + +import ( + "context" + "fmt" + "maps" + "math" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/apache/plc4x/plc4go/spi/errors" +) + +// Demo is a Session backed by a synthetic plant instead of a device. +// +// It answers instantly. That is not merely a convenience: pointing a real plc4x driver at an +// in-memory transport was measured to block for sixty seconds waiting for a protocol handshake +// that nothing answers, which in a demo is indistinguishable from a hang. +// +// Values change from reading to reading, so the UI visibly does something, but they are a pure +// function of a per-tag counter rather than of the clock or a random source. A test can +// therefore assert exact values, and two runs of the demo show the same sequence. +type Demo struct { + options DemoOptions + + mu sync.Mutex + // connected holds the connections opened so far, keyed by ID. + connected map[string]ConnectionInfo + // registered holds the drivers registered so far, keyed by protocol code. + registered map[string]DriverInfo + // reads counts how many times each tag has been read, which is what makes the values + // advance deterministically. + reads map[string]int + // written records the last value written to each tag, so a write is observable by a + // subsequent read rather than being silently discarded. + written map[string]string + // closed marks the session as shut down. + closed bool +} + +// DemoOptions configures the demo session. +type DemoOptions struct { + // SubscriptionInterval is the gap between subscription events. Tests set it small; the + // UI leaves it at DefaultSubscriptionInterval so the display is lively but not frantic. + SubscriptionInterval time.Duration + // Now supplies timestamps. Tests inject a fixed or stepping clock to keep output stable. + Now func() time.Time +} + +// DefaultSubscriptionInterval is how often a demo subscription emits when unconfigured. +const DefaultSubscriptionInterval = time.Second + +// DemoProtocol is the protocol code the demo driver answers to. +const DemoProtocol = "demo" + +// The demo plant. Two devices, so the connection list has something to select between. +const ( + DemoDeviceOne = "demo://plant-1" + DemoDeviceTwo = "demo://plant-2" +) + +// demoTag describes one synthetic tag. +type demoTag struct { + address string + name string + dataType string + // unit is appended to a rendered value, when the tag has one. + unit string + // base and amplitude shape the value sequence for numeric tags. + base float64 + amplitude float64 + + readable bool + writable bool + subscribable bool +} + +// demoCatalog is the tag set every demo device exposes. It is deliberately small enough to fit +// a browse pane without scrolling, and varied enough to exercise the value formatting. +var demoCatalog = []demoTag{ + {address: "temp/1", name: "Boiler inlet", dataType: "REAL", unit: "°C", base: 21.5, amplitude: 4, readable: true, subscribable: true}, + {address: "temp/2", name: "Boiler outlet", dataType: "REAL", unit: "°C", base: 68.0, amplitude: 6, readable: true, subscribable: true}, + {address: "press/1", name: "Header pressure", dataType: "DINT", unit: "mbar", base: 1013, amplitude: 12, readable: true, subscribable: true}, + {address: "flow/1", name: "Circuit flow", dataType: "REAL", unit: "l/s", base: 4.2, amplitude: 1.5, readable: true, subscribable: true}, + {address: "motor/run", name: "Pump running", dataType: "BOOL", readable: true, writable: true, subscribable: true}, + {address: "motor/speed", name: "Pump setpoint", dataType: "UINT", unit: "rpm", base: 1450, amplitude: 50, readable: true, writable: true}, + {address: "counter/1", name: "Cycle counter", dataType: "UDINT", base: 84000, amplitude: 0, readable: true, subscribable: true}, + {address: "label/1", name: "Plant label", dataType: "STRING", readable: true, writable: true}, +} + +// NewDemo creates a demo session. The zero DemoOptions is valid. +func NewDemo(options DemoOptions) *Demo { + if options.SubscriptionInterval <= 0 { + options.SubscriptionInterval = DefaultSubscriptionInterval + } + if options.Now == nil { + options.Now = time.Now + } + return &Demo{ + options: options, + connected: map[string]ConnectionInfo{}, + registered: map[string]DriverInfo{}, + reads: map[string]int{}, + written: map[string]string{}, + } +} + +// Protocols returns the demo protocol. Demo mode offers exactly one, so that the UI's protocol +// suggestions cannot lead a user towards a driver that has no device behind it. +func (d *Demo) Protocols() []string { return []string{DemoProtocol} } + +// Drivers lists the registered drivers. +func (d *Demo) Drivers() []DriverInfo { + d.mu.Lock() + defer d.mu.Unlock() + drivers := slices.Collect(maps.Values(d.registered)) + slices.SortFunc(drivers, func(a, b DriverInfo) int { return strings.Compare(a.Code, b.Code) }) + return drivers +} + +// RegisterDriver registers the demo driver. +func (d *Demo) RegisterDriver(protocol string) (DriverInfo, error) { + if protocol != DemoProtocol { + return DriverInfo{}, errors.Errorf("demo mode serves only the %q protocol, not %q", DemoProtocol, protocol) + } + d.mu.Lock() + defer d.mu.Unlock() + if _, exists := d.registered[protocol]; exists { + return DriverInfo{}, errors.Errorf("%s already registered", protocol) + } + info := DriverInfo{Code: DemoProtocol, Name: "Demo Simulator", SupportsDiscovery: true} + d.registered[protocol] = info + return info, nil +} + +// Connections lists the open connections, ordered so the UI list is stable. +func (d *Demo) Connections() []ConnectionInfo { + d.mu.Lock() + defer d.mu.Unlock() + connections := slices.Collect(maps.Values(d.connected)) + slices.SortFunc(connections, func(a, b ConnectionInfo) int { return strings.Compare(a.ID, b.ID) }) + return connections +} + +// Connect opens a demo connection. It accepts the demo devices, and rejects anything else with +// a message naming what is available, since a demo that silently accepts a real connection +// string would look like it had connected to hardware. +func (d *Demo) Connect(_ context.Context, connectionString string) (ConnectionInfo, error) { + d.mu.Lock() + defer d.mu.Unlock() + if d.closed { + return ConnectionInfo{}, errors.New("session is closed") + } + + id := strings.TrimSpace(connectionString) + if !strings.HasPrefix(id, DemoProtocol+"://") { + return ConnectionInfo{}, errors.Errorf( + "demo mode only connects to the simulated devices, try %s or %s", DemoDeviceOne, DemoDeviceTwo) + } + if _, exists := d.connected[id]; exists { + return ConnectionInfo{}, errors.Errorf("%s already connected", id) + } + info := ConnectionInfo{ID: id, Protocol: DemoProtocol, Transport: DemoProtocol} + d.connected[id] = info + return info, nil +} + +// Disconnect closes a demo connection. +func (d *Demo) Disconnect(id string) error { + d.mu.Lock() + defer d.mu.Unlock() + if _, exists := d.connected[id]; !exists { + return errors.Errorf("%s not connected", id) + } + delete(d.connected, id) + return nil +} + +// Discover reports the demo devices. +func (d *Demo) Discover(ctx context.Context, protocol string, sink func(DiscoveryItem)) error { + if protocol != DemoProtocol { + return errors.Errorf("demo mode can only discover the %q protocol", DemoProtocol) + } + for _, device := range []string{DemoDeviceOne, DemoDeviceTwo} { + if err := ctx.Err(); err != nil { + return err + } + sink(DiscoveryItem{ + ConnectionString: device, + Protocol: DemoProtocol, + Transport: DemoProtocol, + Name: "Simulated plant " + strings.TrimPrefix(device, DemoProtocol+"://"), + }) + } + return nil +} + +// Read returns synthetic values for the requested tags. +func (d *Demo) Read(_ context.Context, connectionID string, tags []TagSpec) (ReadResult, error) { + d.mu.Lock() + defer d.mu.Unlock() + if err := d.requireConnected(connectionID); err != nil { + return ReadResult{}, err + } + if len(tags) == 0 { + return ReadResult{}, errors.New("a read request needs at least one tag") + } + + result := ReadResult{Tags: make([]TagResult, 0, len(tags))} + for _, tag := range tags { + result.Tags = append(result.Tags, d.readTagLocked(connectionID, tag)) + } + // A fixed, non-zero duration: the UI shows it, and a real timing would make golden output + // unstable for no benefit. + result.Duration = 4 * time.Millisecond + return result, nil +} + +// Write records values against the tags, so that a following read observes them. +func (d *Demo) Write(_ context.Context, connectionID string, tags []TagSpec) (WriteResult, error) { + d.mu.Lock() + defer d.mu.Unlock() + if err := d.requireConnected(connectionID); err != nil { + return WriteResult{}, err + } + if len(tags) == 0 { + return WriteResult{}, errors.New("a write request needs at least one tag") + } + + result := WriteResult{Tags: make([]TagResult, 0, len(tags))} + for _, tag := range tags { + catalogued, found := lookupDemoTag(tag.Address) + switch { + case !found: + result.Tags = append(result.Tags, TagResult{ + Name: tag.Name, Address: tag.Address, Code: "NOT_FOUND", + }) + case !catalogued.writable: + result.Tags = append(result.Tags, TagResult{ + Name: tag.Name, Address: tag.Address, DataType: catalogued.dataType, Code: "ACCESS_DENIED", + }) + default: + d.written[connectionID+"|"+tag.Address] = tag.Value + result.Tags = append(result.Tags, TagResult{ + Name: tag.Name, Address: tag.Address, DataType: catalogued.dataType, + Value: tag.Value, Code: ResponseCodeOK, + }) + } + } + result.Duration = 3 * time.Millisecond + return result, nil +} + +// Browse lists the demo catalog, filtered by a substring query. +func (d *Demo) Browse(_ context.Context, connectionID, query string) (BrowseResult, error) { + d.mu.Lock() + defer d.mu.Unlock() + if err := d.requireConnected(connectionID); err != nil { + return BrowseResult{}, err + } + + query = strings.ToLower(strings.TrimSpace(query)) + // "*" is the conventional "everything" query, and reads more naturally than an empty one. + if query == "*" { + query = "" + } + + result := BrowseResult{Duration: 6 * time.Millisecond} + for _, tag := range demoCatalog { + if query != "" && + !strings.Contains(strings.ToLower(tag.address), query) && + !strings.Contains(strings.ToLower(tag.name), query) { + continue + } + result.Items = append(result.Items, BrowseItem{ + Address: tag.address, + Name: tag.name, + DataType: tag.dataType, + Readable: tag.readable, + Writable: tag.writable, + Subscribable: tag.subscribable, + }) + } + return result, nil +} + +// Subscribe streams synthetic events until the context is cancelled. +func (d *Demo) Subscribe(ctx context.Context, connectionID string, tags []TagSpec) (<-chan Event, error) { + d.mu.Lock() + if err := d.requireConnected(connectionID); err != nil { + d.mu.Unlock() + return nil, err + } + d.mu.Unlock() + + if len(tags) == 0 { + return nil, errors.New("a subscription needs at least one tag") + } + for _, tag := range tags { + catalogued, found := lookupDemoTag(tag.Address) + if !found { + return nil, errors.Errorf("unknown tag %q, try browsing the connection first", tag.Address) + } + if !catalogued.subscribable { + return nil, errors.Errorf("tag %q cannot be subscribed to", tag.Address) + } + } + + events := make(chan Event) + go func() { + defer close(events) + ticker := time.NewTicker(d.options.SubscriptionInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + event := d.nextSubscriptionEvent(connectionID, tags) + select { + case events <- event: + case <-ctx.Done(): + return + } + } + } + }() + return events, nil +} + +// nextSubscriptionEvent builds one subscription event, advancing the value sequence. +func (d *Demo) nextSubscriptionEvent(connectionID string, tags []TagSpec) Event { + d.mu.Lock() + defer d.mu.Unlock() + event := Event{ + Kind: EventSubscribe, + Connection: connectionID, + Received: d.options.Now(), + Tags: make([]TagResult, 0, len(tags)), + } + for _, tag := range tags { + event.Tags = append(event.Tags, d.readTagLocked(connectionID, tag)) + } + return event +} + +// Close releases the demo connections. +func (d *Demo) Close() error { + d.mu.Lock() + defer d.mu.Unlock() + d.connected = map[string]ConnectionInfo{} + d.closed = true + return nil +} + +// requireConnected reports an error unless the connection is open. Callers hold d.mu. +func (d *Demo) requireConnected(id string) error { + if d.closed { + return errors.New("session is closed") + } + if _, exists := d.connected[id]; !exists { + return errors.Errorf("%s not connected", id) + } + return nil +} + +// readTagLocked produces one tag's value, advancing that tag's read counter. Callers hold d.mu. +func (d *Demo) readTagLocked(connectionID string, tag TagSpec) TagResult { + catalogued, found := lookupDemoTag(tag.Address) + if !found { + return TagResult{Name: tag.Name, Address: tag.Address, Code: "NOT_FOUND"} + } + if !catalogued.readable { + return TagResult{Name: tag.Name, Address: tag.Address, DataType: catalogued.dataType, Code: "ACCESS_DENIED"} + } + + key := connectionID + "|" + tag.Address + // A value written earlier wins, so that a write is visibly effective. + if written, ok := d.written[key]; ok { + return TagResult{ + Name: tag.Name, Address: tag.Address, DataType: catalogued.dataType, + Value: written, Code: ResponseCodeOK, + } + } + + sequence := d.reads[key] + d.reads[key] = sequence + 1 + return TagResult{ + Name: tag.Name, + Address: tag.Address, + DataType: catalogued.dataType, + Value: demoValue(catalogued, sequence), + Code: ResponseCodeOK, + } +} + +// demoValue renders the value of a tag at a point in its sequence. +// +// The sequence is a plain counter and the shape is a sine, so the values move the way a plant +// reading moves rather than jumping about, and they are exactly reproducible. The period is 17, +// a prime, so that two tags read together do not appear to move in lockstep. +func demoValue(tag demoTag, sequence int) string { + switch tag.dataType { + case "BOOL": + // Alternate on a longer cycle than the numeric tags, so the pane is not a flicker. + if (sequence/3)%2 == 0 { + return "true" + } + return "false" + case "STRING": + return "plant-" + strconv.Itoa(sequence%2+1) + case "REAL": + value := tag.base + tag.amplitude*math.Sin(2*math.Pi*float64(sequence)/17) + return withUnit(strconv.FormatFloat(math.Round(value*10)/10, 'f', 1, 64), tag.unit) + case "UDINT": + // A monotonic counter: the one tag that should obviously advance every read. + return withUnit(strconv.Itoa(int(tag.base)+sequence), tag.unit) + default: + value := tag.base + tag.amplitude*math.Sin(2*math.Pi*float64(sequence)/17) + return withUnit(strconv.Itoa(int(math.Round(value))), tag.unit) + } +} + +// withUnit appends a unit when there is one. +func withUnit(value, unit string) string { + if unit == "" { + return value + } + return fmt.Sprintf("%s %s", value, unit) +} + +// lookupDemoTag finds a catalogued tag by address. +func lookupDemoTag(address string) (demoTag, bool) { + for _, tag := range demoCatalog { + if tag.address == address { + return tag, true + } + } + return demoTag{}, false +} + +// DemoTagAddresses lists the demo catalog's addresses, for completion suggestions. +func DemoTagAddresses() []string { + addresses := make([]string, 0, len(demoCatalog)) + for _, tag := range demoCatalog { + addresses = append(addresses, tag.address) + } + return addresses +} + +// Compile-time proof that Demo satisfies the seam. +var _ Session = (*Demo)(nil) diff --git a/plc4go/tools/internal/plcsession/demo_test.go b/plc4go/tools/internal/plcsession/demo_test.go new file mode 100644 index 000000000..d735049e5 --- /dev/null +++ b/plc4go/tools/internal/plcsession/demo_test.go @@ -0,0 +1,368 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package plcsession_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/plcsession" +) + +// fixedClock returns a stepping clock, so timestamps are predictable without being identical. +func fixedClock() func() time.Time { + base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + step := 0 + return func() time.Time { + step++ + return base.Add(time.Duration(step) * time.Second) + } +} + +// connectedDemo returns a demo session with one device connected. +func connectedDemo(t *testing.T) *plcsession.Demo { + t.Helper() + demo := plcsession.NewDemo(plcsession.DemoOptions{ + SubscriptionInterval: time.Millisecond, + Now: fixedClock(), + }) + t.Cleanup(func() { _ = demo.Close() }) + + _, err := demo.RegisterDriver(plcsession.DemoProtocol) + require.NoError(t, err) + _, err = demo.Connect(t.Context(), plcsession.DemoDeviceOne) + require.NoError(t, err) + return demo +} + +func TestDemoRegistersOnlyItsOwnProtocol(t *testing.T) { + demo := plcsession.NewDemo(plcsession.DemoOptions{}) + info, err := demo.RegisterDriver(plcsession.DemoProtocol) + require.NoError(t, err) + assert.Equal(t, plcsession.DemoProtocol, info.Code) + assert.True(t, info.SupportsDiscovery) + + _, err = demo.RegisterDriver("c-bus") + assert.Error(t, err, "demo mode must not pretend to offer a real driver") + + _, err = demo.RegisterDriver(plcsession.DemoProtocol) + assert.Error(t, err, "registering twice should be reported, not ignored") +} + +// TestDemoRefusesRealConnectionStrings matters because a demo that appeared to connect to +// hardware would be actively misleading. +func TestDemoRefusesRealConnectionStrings(t *testing.T) { + demo := plcsession.NewDemo(plcsession.DemoOptions{}) + _, err := demo.Connect(t.Context(), "c-bus://10.0.0.5:10001") + require.Error(t, err) + assert.Contains(t, err.Error(), plcsession.DemoDeviceOne, "the error should point at what does work") +} + +func TestDemoConnectAndDisconnect(t *testing.T) { + demo := plcsession.NewDemo(plcsession.DemoOptions{}) + info, err := demo.Connect(t.Context(), plcsession.DemoDeviceOne) + require.NoError(t, err) + assert.Equal(t, plcsession.DemoDeviceOne, info.ID) + assert.Len(t, demo.Connections(), 1) + + _, err = demo.Connect(t.Context(), plcsession.DemoDeviceOne) + assert.Error(t, err, "connecting twice should be reported") + + require.NoError(t, demo.Disconnect(plcsession.DemoDeviceOne)) + assert.Empty(t, demo.Connections()) + assert.Error(t, demo.Disconnect(plcsession.DemoDeviceOne), "disconnecting twice should be reported") +} + +func TestDemoConnectionsAreSortedForAStableList(t *testing.T) { + demo := plcsession.NewDemo(plcsession.DemoOptions{}) + _, err := demo.Connect(t.Context(), plcsession.DemoDeviceTwo) + require.NoError(t, err) + _, err = demo.Connect(t.Context(), plcsession.DemoDeviceOne) + require.NoError(t, err) + + connections := demo.Connections() + require.Len(t, connections, 2) + assert.Equal(t, plcsession.DemoDeviceOne, connections[0].ID, "a UI list must not reorder between renders") + assert.Equal(t, plcsession.DemoDeviceTwo, connections[1].ID) +} + +func TestDemoOperationsRequireAConnection(t *testing.T) { + demo := plcsession.NewDemo(plcsession.DemoOptions{}) + tags := []plcsession.TagSpec{{Name: "a", Address: "temp/1"}} + + _, readErr := demo.Read(t.Context(), plcsession.DemoDeviceOne, tags) + assert.Error(t, readErr) + _, writeErr := demo.Write(t.Context(), plcsession.DemoDeviceOne, tags) + assert.Error(t, writeErr) + _, browseErr := demo.Browse(t.Context(), plcsession.DemoDeviceOne, "") + assert.Error(t, browseErr) + _, subscribeErr := demo.Subscribe(t.Context(), plcsession.DemoDeviceOne, tags) + assert.Error(t, subscribeErr) +} + +// TestDemoReadIsDeterministic is the property that lets the same code serve demo mode and the +// tests: two identically-driven sessions produce identical values. +func TestDemoReadIsDeterministic(t *testing.T) { + tags := []plcsession.TagSpec{ + {Name: "t1", Address: "temp/1"}, + {Name: "p1", Address: "press/1"}, + {Name: "c1", Address: "counter/1"}, + } + + valuesOf := func() []string { + demo := connectedDemo(t) + var out []string + for range 4 { + result, err := demo.Read(t.Context(), plcsession.DemoDeviceOne, tags) + require.NoError(t, err) + for _, tag := range result.Tags { + out = append(out, tag.Value) + } + } + return out + } + + assert.Equal(t, valuesOf(), valuesOf(), "the demo value sequence must be reproducible") +} + +// TestDemoReadValuesAdvance is the other half: the display must not look frozen. +func TestDemoReadValuesAdvance(t *testing.T) { + demo := connectedDemo(t) + tags := []plcsession.TagSpec{{Name: "c", Address: "counter/1"}} + + first, err := demo.Read(t.Context(), plcsession.DemoDeviceOne, tags) + require.NoError(t, err) + second, err := demo.Read(t.Context(), plcsession.DemoDeviceOne, tags) + require.NoError(t, err) + + assert.NotEqual(t, first.Tags[0].Value, second.Tags[0].Value, + "a counter tag must visibly advance between reads") +} + +func TestDemoReadReportsTagOutcomes(t *testing.T) { + demo := connectedDemo(t) + result, err := demo.Read(t.Context(), plcsession.DemoDeviceOne, []plcsession.TagSpec{ + {Name: "good", Address: "temp/1"}, + {Name: "bogus", Address: "no/such/tag"}, + }) + require.NoError(t, err, "an unknown tag is a per-tag outcome, not a failed request") + require.Len(t, result.Tags, 2) + + assert.True(t, result.Tags[0].Succeeded()) + assert.Equal(t, "REAL", result.Tags[0].DataType) + assert.NotEmpty(t, result.Tags[0].Value) + + assert.False(t, result.Tags[1].Succeeded()) + assert.Equal(t, "NOT_FOUND", result.Tags[1].Code) +} + +func TestDemoReadRejectsAnEmptyRequest(t *testing.T) { + demo := connectedDemo(t) + _, err := demo.Read(t.Context(), plcsession.DemoDeviceOne, nil) + assert.Error(t, err, "an empty read is a mistake worth reporting rather than an empty result") +} + +// TestDemoWriteIsObservableByAFollowingRead is what makes the write form worth using in a +// demo: the value sticks. +func TestDemoWriteIsObservableByAFollowingRead(t *testing.T) { + demo := connectedDemo(t) + write, err := demo.Write(t.Context(), plcsession.DemoDeviceOne, []plcsession.TagSpec{ + {Name: "speed", Address: "motor/speed", Value: "1234"}, + }) + require.NoError(t, err) + require.Len(t, write.Tags, 1) + require.True(t, write.Tags[0].Succeeded(), "motor/speed is writable") + + read, err := demo.Read(t.Context(), plcsession.DemoDeviceOne, []plcsession.TagSpec{ + {Name: "speed", Address: "motor/speed"}, + }) + require.NoError(t, err) + assert.Equal(t, "1234", read.Tags[0].Value, "a written value must be visible to a later read") +} + +func TestDemoWriteRefusesReadOnlyTags(t *testing.T) { + demo := connectedDemo(t) + result, err := demo.Write(t.Context(), plcsession.DemoDeviceOne, []plcsession.TagSpec{ + {Name: "t", Address: "temp/1", Value: "99"}, + }) + require.NoError(t, err) + assert.Equal(t, "ACCESS_DENIED", result.Tags[0].Code, "temp/1 is not writable") +} + +func TestDemoBrowseListsTheCatalogue(t *testing.T) { + demo := connectedDemo(t) + result, err := demo.Browse(t.Context(), plcsession.DemoDeviceOne, "") + require.NoError(t, err) + assert.NotEmpty(t, result.Items) + + addresses := make([]string, 0, len(result.Items)) + for _, item := range result.Items { + addresses = append(addresses, item.Address) + } + assert.ElementsMatch(t, plcsession.DemoTagAddresses(), addresses, + "browse must agree with the completion suggestions") +} + +func TestDemoBrowseFiltersByQuery(t *testing.T) { + demo := connectedDemo(t) + for query, wantSome := range map[string]bool{ + "temp": true, + "motor": true, + "Boiler": true, + "*": true, + "": true, + "nothing": false, + } { + t.Run(query, func(t *testing.T) { + result, err := demo.Browse(t.Context(), plcsession.DemoDeviceOne, query) + require.NoError(t, err) + if wantSome { + assert.NotEmpty(t, result.Items, "query %q should match something", query) + } else { + assert.Empty(t, result.Items, "query %q should match nothing", query) + } + }) + } +} + +func TestDemoBrowseStarMeansEverything(t *testing.T) { + demo := connectedDemo(t) + all, err := demo.Browse(t.Context(), plcsession.DemoDeviceOne, "") + require.NoError(t, err) + star, err := demo.Browse(t.Context(), plcsession.DemoDeviceOne, "*") + require.NoError(t, err) + assert.Equal(t, all.Items, star.Items) +} + +func TestDemoDiscoverFindsBothDevices(t *testing.T) { + demo := plcsession.NewDemo(plcsession.DemoOptions{}) + var found []plcsession.DiscoveryItem + require.NoError(t, demo.Discover(t.Context(), plcsession.DemoProtocol, func(item plcsession.DiscoveryItem) { + found = append(found, item) + })) + require.Len(t, found, 2) + assert.Equal(t, plcsession.DemoDeviceOne, found[0].ConnectionString) + assert.Equal(t, plcsession.DemoDeviceTwo, found[1].ConnectionString) +} + +func TestDemoDiscoverStopsOnACancelledContext(t *testing.T) { + demo := plcsession.NewDemo(plcsession.DemoOptions{}) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + err := demo.Discover(ctx, plcsession.DemoProtocol, func(plcsession.DiscoveryItem) {}) + assert.Error(t, err) +} + +// TestDemoSubscribeEmitsEventsAndStopsOnCancel covers the behaviour the message pane depends +// on, including that cancelling closes the channel rather than leaking the producer. +func TestDemoSubscribeEmitsEventsAndStopsOnCancel(t *testing.T) { + demo := connectedDemo(t) + ctx, cancel := context.WithCancel(t.Context()) + + events, err := demo.Subscribe(ctx, plcsession.DemoDeviceOne, []plcsession.TagSpec{ + {Name: "t", Address: "temp/1"}, + }) + require.NoError(t, err) + + for i := range 3 { + select { + case event, ok := <-events: + require.True(t, ok, "event %d: channel closed early", i) + assert.Equal(t, plcsession.EventSubscribe, event.Kind) + assert.Equal(t, plcsession.DemoDeviceOne, event.Connection) + require.Len(t, event.Tags, 1) + assert.True(t, event.Tags[0].Succeeded()) + assert.False(t, event.Received.IsZero(), "an event needs a timestamp for the message list") + case <-time.After(2 * time.Second): + t.Fatalf("event %d never arrived", i) + } + } + + cancel() + // Drain until closed: the channel must close, or the UI would hold a live goroutine. + deadline := time.After(2 * time.Second) + for { + select { + case _, ok := <-events: + if !ok { + return + } + case <-deadline: + t.Fatal("cancelling the context did not close the event channel") + } + } +} + +func TestDemoSubscribeRejectsUnsubscribableAndUnknownTags(t *testing.T) { + demo := connectedDemo(t) + _, err := demo.Subscribe(t.Context(), plcsession.DemoDeviceOne, []plcsession.TagSpec{ + {Name: "l", Address: "label/1"}, + }) + assert.Error(t, err, "label/1 is not subscribable") + + _, err = demo.Subscribe(t.Context(), plcsession.DemoDeviceOne, []plcsession.TagSpec{ + {Name: "x", Address: "no/such/tag"}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "browsing", "the error should suggest how to find a real tag") +} + +func TestDemoSubscribeRejectsAnEmptyTagList(t *testing.T) { + demo := connectedDemo(t) + _, err := demo.Subscribe(t.Context(), plcsession.DemoDeviceOne, nil) + assert.Error(t, err) +} + +func TestDemoCloseIsIdempotentAndReleasesConnections(t *testing.T) { + demo := connectedDemo(t) + require.NotEmpty(t, demo.Connections()) + + require.NoError(t, demo.Close()) + assert.Empty(t, demo.Connections()) + require.NoError(t, demo.Close(), "Close must be safe to call twice") + + _, err := demo.Connect(t.Context(), plcsession.DemoDeviceTwo) + assert.Error(t, err, "a closed session must not accept new connections") +} + +// TestDemoValuesCoverEveryDataType guards the value renderer against a data type being added +// to the catalogue without a rendering rule. +func TestDemoValuesCoverEveryDataType(t *testing.T) { + demo := connectedDemo(t) + for _, address := range plcsession.DemoTagAddresses() { + t.Run(address, func(t *testing.T) { + result, err := demo.Read(t.Context(), plcsession.DemoDeviceOne, + []plcsession.TagSpec{{Name: "x", Address: address}}) + require.NoError(t, err) + require.Len(t, result.Tags, 1) + assert.True(t, result.Tags[0].Succeeded(), "catalogue tag %s should read", address) + assert.NotEmpty(t, result.Tags[0].Value, "catalogue tag %s produced no value", address) + assert.NotEmpty(t, result.Tags[0].DataType) + }) + } +} + +func TestDemoProtocolsOffersOnlyDemo(t *testing.T) { + assert.Equal(t, []string{plcsession.DemoProtocol}, plcsession.NewDemo(plcsession.DemoOptions{}).Protocols()) +} diff --git a/plc4go/tools/internal/plcsession/frames.go b/plc4go/tools/internal/plcsession/frames.go new file mode 100644 index 000000000..245b42960 --- /dev/null +++ b/plc4go/tools/internal/plcsession/frames.go @@ -0,0 +1,214 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package plcsession + +import ( + "context" + "net/url" + "sync" + "time" + + "github.com/apache/plc4x/plc4go/spi/options" + "github.com/apache/plc4x/plc4go/spi/transports" +) + +// Raw frame capture. +// +// The browser can show the bytes a request actually put on the wire, which is the one thing it +// could never do before and the reason the pcap analyzer exists as a separate tool. plc4x has +// no public byte tap: spi/tracer records operations, but its TraceEntry.Message is a string, so +// it carries "read" and "SUCCESS" rather than octets. +// +// The bytes are therefore taken where they exist, at the transport. Transport and +// TransportInstance are both interfaces, so a decorator that embeds one and overrides only +// Read and Write is small and cannot drift as the interface grows. + +// FrameDirection says which way a captured frame travelled. +type FrameDirection string + +// The frame directions, named from the tool's point of view. +const ( + // FrameOutbound is a frame this tool sent. + FrameOutbound FrameDirection = "out" + // FrameInbound is a frame this tool received. + FrameInbound FrameDirection = "in" +) + +// Frame is one chunk of bytes as it crossed the transport. +// +// A transport is a byte stream, not a sequence of messages, so a Frame is one Read or Write +// rather than one protocol message: a single message may span several frames and one frame may +// carry several messages. The UI says so rather than implying a framing that is not there. +type Frame struct { + // At is when the bytes crossed the transport. + At time.Time + // Direction says which way they went. + Direction FrameDirection + // Bytes is a copy of the payload, so a later reuse of the driver's buffer cannot change + // what the UI displays. + Bytes []byte +} + +// FrameLog is a bounded, concurrency-safe record of captured frames. +// +// It is written from the driver's goroutines and read by the user interface, so it is guarded. +// It is bounded because a busy connection would otherwise grow it without limit: a terminal +// can show a few hundred frames and a debugging session needs the most recent ones. +type FrameLog struct { + mu sync.Mutex + frames []Frame + limit int +} + +// DefaultFrameLimit is how many frames a FrameLog keeps when unconfigured. +const DefaultFrameLimit = 500 + +// NewFrameLog creates a frame log. A limit of zero or less means DefaultFrameLimit. +func NewFrameLog(limit int) *FrameLog { + if limit <= 0 { + limit = DefaultFrameLimit + } + return &FrameLog{limit: limit} +} + +// Add records a frame, copying the bytes. +func (l *FrameLog) Add(direction FrameDirection, at time.Time, data []byte) { + if l == nil || len(data) == 0 { + return + } + copied := make([]byte, len(data)) + copy(copied, data) + + l.mu.Lock() + defer l.mu.Unlock() + l.frames = append(l.frames, Frame{At: at, Direction: direction, Bytes: copied}) + if len(l.frames) > l.limit { + l.frames = l.frames[len(l.frames)-l.limit:] + } +} + +// Frames returns a copy of everything recorded. +func (l *FrameLog) Frames() []Frame { + if l == nil { + return nil + } + l.mu.Lock() + defer l.mu.Unlock() + return append([]Frame(nil), l.frames...) +} + +// Between returns the frames captured in a time window, inclusive at both ends. +// +// This is how a request is associated with its bytes. A transport carries no request +// identifier, so the association is temporal: the frames that crossed while the request was in +// flight. That is an approximation, and on a connection carrying a subscription it will include +// unrelated traffic, which is why the UI presents it as "frames during this request" rather +// than as the request's own bytes. +func (l *FrameLog) Between(from, to time.Time) []Frame { + if l == nil { + return nil + } + l.mu.Lock() + defer l.mu.Unlock() + + var out []Frame + for _, frame := range l.frames { + if frame.At.Before(from) || frame.At.After(to) { + continue + } + out = append(out, frame) + } + return out +} + +// Len reports how many frames are held. +func (l *FrameLog) Len() int { + if l == nil { + return 0 + } + l.mu.Lock() + defer l.mu.Unlock() + return len(l.frames) +} + +// Reset discards everything recorded. +func (l *FrameLog) Reset() { + if l == nil { + return + } + l.mu.Lock() + defer l.mu.Unlock() + l.frames = nil +} + +// recordingTransport wraps a transport so that every instance it creates is recorded. +// +// It reports the wrapped transport's own code and name, because the driver looks a transport up +// by code: a decorator that renamed itself would simply not be found. +type recordingTransport struct { + transports.Transport + log *FrameLog + clock func() time.Time +} + +// newRecordingTransport wraps inner so that the bytes it carries are recorded into log. +func newRecordingTransport(inner transports.Transport, log *FrameLog, clock func() time.Time) transports.Transport { + if clock == nil { + clock = time.Now + } + return &recordingTransport{Transport: inner, log: log, clock: clock} +} + +// CreateTransportInstance wraps the created instance. +func (t *recordingTransport) CreateTransportInstance(transportUrl url.URL, opts map[string][]string, _options ...options.WithOption) (transports.TransportInstance, error) { + inner, err := t.Transport.CreateTransportInstance(transportUrl, opts, _options...) + if err != nil || inner == nil { + return inner, err + } + return &recordingInstance{TransportInstance: inner, log: t.log, clock: t.clock}, nil +} + +// recordingInstance records the bytes crossing one transport instance. +// +// Only Read and Write are overridden. Everything else, including the buffer-filling and +// peeking that a codec uses to find a message boundary, is delegated: peeking does not consume +// bytes, so recording it would double-count them. +type recordingInstance struct { + transports.TransportInstance + log *FrameLog + clock func() time.Time +} + +// Read records the bytes it returns. +func (i *recordingInstance) Read(ctx context.Context, numBytes uint32) ([]byte, error) { + data, err := i.TransportInstance.Read(ctx, numBytes) + i.log.Add(FrameInbound, i.clock(), data) + return data, err +} + +// Write records the bytes it sends. +// +// The frame is recorded even when the write fails: a request that went out and was rejected is +// exactly the case someone reaches for a byte view to understand. +func (i *recordingInstance) Write(ctx context.Context, data []byte) error { + err := i.TransportInstance.Write(ctx, data) + i.log.Add(FrameOutbound, i.clock(), data) + return err +} diff --git a/plc4go/tools/internal/plcsession/frames_test.go b/plc4go/tools/internal/plcsession/frames_test.go new file mode 100644 index 000000000..5d668cb03 --- /dev/null +++ b/plc4go/tools/internal/plcsession/frames_test.go @@ -0,0 +1,241 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package plcsession_test + +import ( + "net/url" + "sync" + "testing" + "time" + + plc4go "github.com/apache/plc4x/plc4go/pkg/api" + "github.com/apache/plc4x/plc4go/spi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/plcsession" +) + +// at is a timestamp a fixed number of milliseconds into the test's epoch. +func at(millis int) time.Time { + return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC).Add(time.Duration(millis) * time.Millisecond) +} + +func TestAFrameLogRecordsBothDirections(t *testing.T) { + log := plcsession.NewFrameLog(0) + log.Add(plcsession.FrameOutbound, at(0), []byte("~~~\r")) + log.Add(plcsession.FrameInbound, at(1), []byte("322100AD\r\n")) + + frames := log.Frames() + require.Len(t, frames, 2) + assert.Equal(t, plcsession.FrameOutbound, frames[0].Direction) + assert.Equal(t, "~~~\r", string(frames[0].Bytes)) + assert.Equal(t, plcsession.FrameInbound, frames[1].Direction) +} + +// TestAFrameLogCopiesTheBytes matters because a driver reuses its read buffer: holding the +// caller's slice would make the display change under the user. +func TestAFrameLogCopiesTheBytes(t *testing.T) { + log := plcsession.NewFrameLog(0) + buffer := []byte("original") + log.Add(plcsession.FrameInbound, at(0), buffer) + + copy(buffer, "clobbered") + assert.Equal(t, "original", string(log.Frames()[0].Bytes), + "a recorded frame must not change when the driver reuses its buffer") +} + +func TestAFrameLogIsBounded(t *testing.T) { + log := plcsession.NewFrameLog(4) + for i := range 20 { + log.Add(plcsession.FrameInbound, at(i), []byte{byte(i)}) + } + frames := log.Frames() + require.Len(t, frames, 4, "a busy connection must not grow the log without limit") + // The most recent frames are the ones worth keeping. + assert.Equal(t, byte(19), frames[3].Bytes[0]) + assert.Equal(t, byte(16), frames[0].Bytes[0]) +} + +func TestAFrameLogIgnoresEmptyReads(t *testing.T) { + log := plcsession.NewFrameLog(0) + log.Add(plcsession.FrameInbound, at(0), nil) + log.Add(plcsession.FrameInbound, at(1), []byte{}) + assert.Zero(t, log.Len(), "a zero-length read is not a frame") +} + +// TestBetweenSelectsTheFramesOfARequest is how a request is associated with its bytes: the +// transport carries no request identifier, so the association is the time window. +func TestBetweenSelectsTheFramesOfARequest(t *testing.T) { + log := plcsession.NewFrameLog(0) + log.Add(plcsession.FrameOutbound, at(0), []byte("before")) + log.Add(plcsession.FrameOutbound, at(10), []byte("request")) + log.Add(plcsession.FrameInbound, at(15), []byte("reply")) + log.Add(plcsession.FrameInbound, at(30), []byte("after")) + + during := log.Between(at(10), at(20)) + require.Len(t, during, 2) + assert.Equal(t, "request", string(during[0].Bytes)) + assert.Equal(t, "reply", string(during[1].Bytes)) +} + +func TestBetweenIsInclusiveAtBothEnds(t *testing.T) { + log := plcsession.NewFrameLog(0) + log.Add(plcsession.FrameOutbound, at(10), []byte("start")) + log.Add(plcsession.FrameInbound, at(20), []byte("end")) + assert.Len(t, log.Between(at(10), at(20)), 2, + "a frame exactly on the boundary belongs to the request") +} + +func TestFrameLogResetDiscardsEverything(t *testing.T) { + log := plcsession.NewFrameLog(0) + log.Add(plcsession.FrameInbound, at(0), []byte("x")) + log.Reset() + assert.Zero(t, log.Len()) +} + +// TestANilFrameLogIsSafe keeps capture optional without a nil check at every call site. +func TestANilFrameLogIsSafe(t *testing.T) { + var log *plcsession.FrameLog + assert.NotPanics(t, func() { + log.Add(plcsession.FrameInbound, at(0), []byte("x")) + assert.Zero(t, log.Len()) + assert.Nil(t, log.Frames()) + assert.Nil(t, log.Between(at(0), at(1))) + log.Reset() + }) +} + +// TestAFrameLogIsSafeUnderConcurrentUse: the driver writes from its own goroutines while the +// interface reads, so this runs under -race in CI. +func TestAFrameLogIsSafeUnderConcurrentUse(t *testing.T) { + log := plcsession.NewFrameLog(64) + var group sync.WaitGroup + for writer := range 4 { + group.Add(1) + go func(writer int) { + defer group.Done() + for i := range 50 { + log.Add(plcsession.FrameInbound, at(i), []byte{byte(writer)}) + } + }(writer) + } + group.Go(func() { + for range 50 { + _ = log.Frames() + _ = log.Len() + } + }) + group.Wait() + assert.LessOrEqual(t, log.Len(), 64) +} + +// TestFrameCaptureRegistersTheRecordingTransportsFirst pins the ordering the capture depends +// on. plc4x keeps the FIRST transport registered under a code and skips the rest, so the +// decorator has to be installed before any driver registers its own. +// +// All three transports, up front, whether or not a driver has asked for one yet: which drivers +// the user will register is unknowable at construction time, and this is the only moment at +// which the decorator can win the race. A transport missed here has no capture at all, and an +// empty byte view is worse than no byte view - it says the wire was silent. +func TestFrameCaptureRegistersTheRecordingTransportsFirst(t *testing.T) { + manager := plc4go.NewPlcDriverManager() + log := plcsession.NewFrameLog(0) + session := plcsession.NewLive(plcsession.LiveOptions{DriverManager: manager, Frames: log}) + t.Cleanup(func() { _ = session.Close() }) + + aware, ok := manager.(spi.TransportAware) + require.True(t, ok) + names := aware.ListTransportNames() + assert.Contains(t, names, "tcp", "capture must install a tcp transport up front") + assert.Contains(t, names, "udp") + assert.Contains(t, names, "serial", "firmata and the serial Modbus drivers need capture too") + + // Registering drivers afterwards must not displace the recording transports. c-bus dials + // over tcp and firmata over serial, so both the long-standing case and the new one are + // covered. + for _, protocol := range []string{"c-bus", "firmata"} { + _, err := session.RegisterDriver(protocol) + require.NoError(t, err) + assert.ElementsMatch(t, names, aware.ListTransportNames(), + "%s registering its own transport must not replace the recording one", protocol) + } +} + +// TestFrameCaptureRecordsBytesOnEveryTransportItInstalled is the assertion that the transport +// names alone cannot make: that what was installed under each code actually records. +// +// Checking the names only pins that a transport is present, so a session that registered +// plain transports instead of the recording decorator passes every other test in this file +// and then shows an empty byte view - which does not read as a missing feature, it reads as +// "the wire was silent". So the bytes are put through each installed transport and read back +// out of the log. +// +// Nothing is dialled. Each instance is created and written to unconnected: the socket +// transports resolve a numeric address locally, the serial one only builds, and every one of +// them refuses the write. The decorator records regardless, which is deliberate - a request +// that went out and failed is exactly what someone opens a byte view to look at. +func TestFrameCaptureRecordsBytesOnEveryTransportItInstalled(t *testing.T) { + manager := plc4go.NewPlcDriverManager() + log := plcsession.NewFrameLog(0) + session := plcsession.NewLive(plcsession.LiveOptions{DriverManager: manager, Frames: log}) + t.Cleanup(func() { _ = session.Close() }) + aware, ok := manager.(spi.TransportAware) + require.True(t, ok) + + // A host for the socket transports and a device path for serial, because that is the field + // each one reads its target out of. + for _, probe := range []struct { + code string + transportUrl url.URL + }{ + {code: "tcp", transportUrl: url.URL{Scheme: "tcp", Host: "127.0.0.1:1"}}, + {code: "udp", transportUrl: url.URL{Scheme: "udp", Host: "127.0.0.1:1"}}, + {code: "serial", transportUrl: url.URL{Scheme: "serial", Path: "/dev/ttyUSB0"}}, + } { + t.Run(probe.code, func(t *testing.T) { + log.Reset() + transport, err := aware.GetTransport(probe.code, "", nil) + require.NoError(t, err, "capture must have installed a %s transport", probe.code) + instance, err := transport.CreateTransportInstance(probe.transportUrl, nil) + require.NoError(t, err) + + _ = instance.Write(t.Context(), []byte("~~~\r")) + + frames := log.Frames() + require.Len(t, frames, 1, "a plain %s transport would have recorded nothing", probe.code) + assert.Equal(t, plcsession.FrameOutbound, frames[0].Direction) + assert.Equal(t, "~~~\r", string(frames[0].Bytes)) + }) + } +} + +func TestFrameCaptureIsOffByDefault(t *testing.T) { + session := plcsession.NewLive(plcsession.LiveOptions{}) + t.Cleanup(func() { _ = session.Close() }) + assert.Nil(t, session.FrameLog(), "capture costs a copy of every byte, so it is opt-in") +} + +func TestFrameLogIsReachableWhenCaptureIsOn(t *testing.T) { + log := plcsession.NewFrameLog(0) + session := plcsession.NewLive(plcsession.LiveOptions{Frames: log}) + t.Cleanup(func() { _ = session.Close() }) + assert.Same(t, log, session.FrameLog()) +} diff --git a/plc4go/tools/internal/plcsession/live.go b/plc4go/tools/internal/plcsession/live.go new file mode 100644 index 000000000..e672adfba --- /dev/null +++ b/plc4go/tools/internal/plcsession/live.go @@ -0,0 +1,965 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package plcsession + +import ( + "context" + "maps" + "net/url" + "slices" + "strings" + "sync" + "time" + + plc4go "github.com/apache/plc4x/plc4go/pkg/api" + "github.com/apache/plc4x/plc4go/pkg/api/config" + "github.com/apache/plc4x/plc4go/pkg/api/drivers" + apiModel "github.com/apache/plc4x/plc4go/pkg/api/model" + "github.com/apache/plc4x/plc4go/pkg/api/transports" + "github.com/apache/plc4x/plc4go/pkg/api/values" + "github.com/apache/plc4x/plc4go/spi" + "github.com/apache/plc4x/plc4go/spi/errors" + spiTransports "github.com/apache/plc4x/plc4go/spi/transports" + "github.com/apache/plc4x/plc4go/spi/transports/serial" + "github.com/apache/plc4x/plc4go/spi/transports/tcp" + "github.com/apache/plc4x/plc4go/spi/transports/udp" +) + +// DefaultOperationTimeout bounds every operation when LiveOptions leaves the timeout unset. +// +// A bound is not optional. A plc4x driver pointed at a host that never completes its protocol +// handshake was measured blocking for sixty seconds, which in a terminal user interface is +// indistinguishable from a hang: the user's only evidence is a spinner. Ten seconds is long +// enough for a slow PLC on a slow link and short enough that a mistyped connection string +// reports itself while the user still remembers typing it. +const DefaultOperationTimeout = 10 * time.Second + +// liveTransport names the transport a driver dials over. +type liveTransport int + +const ( + transportTCP liveTransport = iota + transportUDP + transportSerial +) + +// liveTransportDefinition is everything the session needs to know about one transport: the code +// plc4x registers it under, the call that registers a plain one, and how to build one for the +// frame-capture decorator to wrap. +// +// The three facts are deliberately held together. They used to be spread over a pair of +// booleans, an if/else in ensureTransportLocked and two literal calls in installFrameCapture, +// so adding the serial transport meant editing three places - and forgetting the capture one +// would have been the worst of the three failures, because a serial connection would then show +// an empty byte view and so claim the wire had been silent. +type liveTransportDefinition struct { + // code is what plc4x knows the transport as, and what a connection string names. + code string + // register registers a plain transport with a driver manager. + register func(plc4go.PlcDriverManager, ...config.WithOption) + // spiTransport builds an unregistered transport, for installFrameCapture to wrap. + spiTransport func() spiTransports.Transport +} + +// liveTransports describes every transport the supported drivers dial over. +// +// serial is the odd one out: firmata, modbus-rtu and modbus-ascii dial over a serial port +// rather than a socket, so a connection string names a device in its PATH and has no host at +// all - modbus-rtu:///dev/ttyUSB0. plc4x takes the port name from url.Path alone, and net/url +// rejects a backslash in a host, so a Windows port cannot be spelled \\.\COM3 in a connection +// string whatever the transport would like. Nothing about a serial connection can be reached +// over a network. +var liveTransports = map[liveTransport]liveTransportDefinition{ + transportTCP: { + code: "tcp", + register: transports.RegisterTcpTransport, + spiTransport: func() spiTransports.Transport { return tcp.NewTransport() }, + }, + transportUDP: { + code: "udp", + register: transports.RegisterUdpTransport, + spiTransport: func() spiTransports.Transport { return udp.NewTransport() }, + }, + transportSerial: { + code: "serial", + register: transports.RegisterSerialTransport, + spiTransport: func() spiTransports.Transport { return serial.NewTransport() }, + }, +} + +// liveDriver ties a protocol code to the plc4x call that registers its driver and to the +// transport that driver needs. +// +// The transport is stated rather than read from the driver's GetDefaultTransport because it is +// needed before there is a driver to ask: ensureTransportLocked has to know which transport to +// have ready. The two are checked against each other by a test that registers every protocol +// and asserts the driver's own default transport ended up registered. +type liveDriver struct { + register func(plc4go.PlcDriverManager, ...config.WithOption) plc4go.PlcDriver + transport liveTransport +} + +// liveDrivers is the protocol registry: the single place where the set of supported protocols +// and the way to register each one are stated. +// +// The previous UI kept those two facts apart - a comma-separated `protocols` constant in one +// file and a switch statement in another - and they drifted, twice. One copy of the constant +// had lost "opcua" while the switch still handled it, and the constant said "bacnetip" while +// the driver answers to "bacnet-ip" (see liveProtocolAliases). Deriving Protocols() from this +// map makes that particular drift impossible rather than merely unlikely. +// +// Every public plc4x driver is here. The previous UI offered five of the sixteen, which left +// eleven protocols - every Modbus flavour among them - unreachable from a tool whose entire +// purpose is to talk to a device someone is holding. Each key is the code its own driver +// answers to, verified by a test rather than copied from documentation: "bacnet-ip" not +// "bacnetip", "knxnet-ip" not "knx", and "iec-60870-5-104" in full. +var liveDrivers = map[string]liveDriver{ + "ab-eth": {register: drivers.RegisterAbEthDriver, transport: transportTCP}, + "ads": {register: drivers.RegisterAdsDriver, transport: transportTCP}, + "bacnet-ip": {register: drivers.RegisterBacnetDriver, transport: transportUDP}, + "c-bus": {register: drivers.RegisterCBusDriver, transport: transportTCP}, + "eip": {register: drivers.RegisterEipDriver, transport: transportTCP}, + "firmata": {register: drivers.RegisterFirmataDriver, transport: transportSerial}, + "iec-60870-5-104": {register: drivers.RegisterIec608705104Driver, transport: transportTCP}, + "knxnet-ip": {register: drivers.RegisterKnxDriver, transport: transportUDP}, + "logix": {register: drivers.RegisterLogixDriver, transport: transportTCP}, + "modbus-ascii": {register: drivers.RegisterModbusAsciiDriver, transport: transportSerial}, + "modbus-rtu": {register: drivers.RegisterModbusRtuDriver, transport: transportSerial}, + "modbus-tcp": {register: drivers.RegisterModbusTcpDriver, transport: transportTCP}, + "opcua": {register: drivers.RegisterOpcuaDriver, transport: transportTCP}, + "s7": {register: drivers.RegisterS7Driver, transport: transportTCP}, + "slmp": {register: drivers.RegisterSlmpDriver, transport: transportTCP}, + "umas": {register: drivers.RegisterUmasDriver, transport: transportTCP}, +} + +// liveProtocolAliases accepts the names the previous UI used for a protocol whose driver +// answers to a different code. +// +// "bacnetip" is the one that mattered. The old browser listed it, keyed its own driver map by +// it, and offered it as a completion - but plc4x registers that driver as "bacnet-ip", so +// `connect bacnetip://...` could only ever fail with "couldn't find driver bacnetip" while +// `discover bacnet-ip` failed the other way round. The same bacnet/bacnetip mismatch is a +// known defect in the pcap analyzer. Canonicalising here means either spelling works and both +// end up at one registration. +var liveProtocolAliases = map[string]string{ + "bacnet": "bacnet-ip", + "bacnetip": "bacnet-ip", +} + +// browseFlattenDepth caps how deep a browse result tree is flattened. It is a guard, not a +// policy: a driver that answered with a cyclic tree would otherwise spin the UI's command +// goroutine forever, and no real address space is eight levels deep. +const browseFlattenDepth = 8 + +// liveBrowseQueryName is the name the single browse query is submitted under. plc4x reports +// per-query response codes by name, so it needs one. +const liveBrowseQueryName = "browse" + +// LiveOptions configures a live session. +type LiveOptions struct { + // DriverManager is the plc4x driver manager to register drivers with. A nil manager means + // the session creates and owns one, which is what the application does; a test or an + // embedder injects its own. + DriverManager plc4go.PlcDriverManager + // Now supplies timestamps and measures durations. Injectable so a test can pin both. It is + // called from driver-owned goroutines while a subscription is running, so an injected clock + // has to be safe for concurrent use. + Now func() time.Time + // Timeout bounds every single operation. Zero means DefaultOperationTimeout. + Timeout time.Duration + // Frames, when set, records the bytes crossing the transport so the user interface can show + // what a request actually put on the wire. + // + // Capture has to be arranged before any driver is registered: a driver registers its own + // transport, and plc4x keeps the first registration for a transport code and skips the + // rest. NewLive therefore installs the recording transports up front. + Frames *FrameLog +} + +// liveConnection is one open connection together with the identity the UI addresses it by. +type liveConnection struct { + info ConnectionInfo + connection plc4go.PlcConnection +} + +// Live is a Session backed by real plc4x drivers talking to real devices. +// +// Every operation runs under a timeout (see DefaultOperationTimeout), including Connect: the +// whole point of the seam is that the user interface stays answerable, and a driver waiting on +// a handshake will not volunteer to give up. +// +// Unlike the Session contract's minimum, Live is safe for concurrent use. A subscription +// delivers events from a driver-owned goroutine while the UI is free to read or disconnect, so +// the maps have to be guarded whether the interface demands it or not. +type Live struct { + options LiveOptions + + // driverManager is held apart from options because a nil option means "create one". + driverManager plc4go.PlcDriverManager + // ownsDriverManager records whether Close should shut the manager down. An injected + // manager belongs to whoever injected it. + ownsDriverManager bool + + mu sync.Mutex + // registered holds what has been registered so far, keyed by canonical protocol code. + registered map[string]DriverInfo + // drivers holds the driver objects, which Discover needs. + drivers map[string]plc4go.PlcDriver + // connections holds the open connections, keyed by ID. + connections map[string]liveConnection + // transportsRegistered latches the transport registrations, so that at most one + // registration per transport is ever attempted from here. See ensureTransportLocked for + // why that matters and why it is not the same latch the previous UI had. + transportsRegistered map[liveTransport]bool + // closed marks the session as shut down. + closed bool +} + +// NewLive creates a live session. The zero LiveOptions is valid and yields an owned driver +// manager, the wall clock and DefaultOperationTimeout. +func NewLive(options LiveOptions) *Live { + if options.Now == nil { + options.Now = time.Now + } + if options.Timeout <= 0 { + options.Timeout = DefaultOperationTimeout + } + live := &Live{ + options: options, + driverManager: options.DriverManager, + registered: map[string]DriverInfo{}, + drivers: map[string]plc4go.PlcDriver{}, + connections: map[string]liveConnection{}, + transportsRegistered: map[liveTransport]bool{}, + } + if live.driverManager == nil { + live.driverManager = plc4go.NewPlcDriverManager() + live.ownsDriverManager = true + } + live.installFrameCapture() + return live +} + +// installFrameCapture registers recording transports, if capture was asked for. +// +// It runs before any driver is registered, and that ordering is the whole trick: plc4x keeps +// the first transport registered under a code and skips later ones, so the decorator has to get +// there before the driver's own registration. ensureTransportLocked then finds the code already +// present and leaves it alone. +func (l *Live) installFrameCapture() { + if l.options.Frames == nil { + return + } + aware, ok := l.driverManager.(spi.TransportAware) + if !ok { + // An injected manager that is not transport-aware cannot be tapped. Capture is a + // convenience, so this is silent rather than fatal. + return + } + // Every transport, not only the ones some driver has asked for yet: which drivers the user + // will register is not known at construction time, and the ordering above is only available + // once, before the first driver arrives. Sorted so two runs register them the same way. + for _, transport := range slices.Sorted(maps.Keys(liveTransports)) { + definition := liveTransports[transport] + aware.RegisterTransport(newRecordingTransport(definition.spiTransport(), l.options.Frames, l.options.Now)) + l.transportsRegistered[transport] = true + } +} + +// FrameLog returns the frame log this session records into, or nil when capture is off. +func (l *Live) FrameLog() *FrameLog { return l.options.Frames } + +// Protocols lists the protocol codes that can be registered, in the spelling a connection +// string must use. +func (l *Live) Protocols() []string { + return slices.Sorted(maps.Keys(liveDrivers)) +} + +// Drivers lists the registered drivers, ordered so the UI list is stable between renders. +func (l *Live) Drivers() []DriverInfo { + l.mu.Lock() + defer l.mu.Unlock() + registered := slices.Collect(maps.Values(l.registered)) + slices.SortFunc(registered, func(a, b DriverInfo) int { return strings.Compare(a.Code, b.Code) }) + return registered +} + +// RegisterDriver registers a protocol's plc4x driver and the transport it dials over. +// +// The DriverInfo is read back off the driver rather than restated here, so what the UI shows +// is what the driver will actually answer to. +func (l *Live) RegisterDriver(protocol string) (DriverInfo, error) { + code := canonicalLiveProtocol(protocol) + definition, known := liveDrivers[code] + if !known { + return DriverInfo{}, errors.Errorf("unknown protocol %q, one of %s can be registered", + protocol, strings.Join(l.Protocols(), ", ")) + } + + l.mu.Lock() + defer l.mu.Unlock() + if l.closed { + return DriverInfo{}, errors.New("session is closed") + } + if _, exists := l.registered[code]; exists { + return DriverInfo{}, errors.Errorf("%s already registered", code) + } + // Checked before registering anything: plc4x's transport helpers type-assert the driver + // manager to spi.TransportAware without checking, and drivers.Register*Driver calls one of + // those helpers itself, so an injected manager that cannot carry transports would panic + // deep inside plc4x rather than in a UI command goroutine that could report it. + if _, aware := l.driverManager.(spi.TransportAware); !aware { + return DriverInfo{}, errors.Errorf("can't register %s: the driver manager cannot register transports", code) + } + + // The check below has to follow the registration, not precede it: plc4x couples creating a + // driver with registering it -- drivers.Register*Driver does both in one call and there is + // no way to build one without registering it -- so its own code cannot be read until it is + // already in the manager. A key that disagrees therefore leaves a driver registered inside + // plc4x that the session does not record. That is untidy but unreachable, since all + // sixteen real drivers agree, and it is preferable to not checking at all. + driver := definition.register(l.driverManager) + // A driver that answers to a code other than the key it was registered under is a defect, + // and a silent one: plc4x finds a driver by the scheme in the connection string, and Connect + // rewrites that scheme to this key, so every later connection would fail with "couldn't find + // driver " on a driver the session had just reported as registered. That is the + // bacnetip/bacnet-ip defect in a new disguise, so it is reported here instead of being left + // for the user to meet at the prompt. + if answered := driver.GetProtocolCode(); answered != code { + return DriverInfo{}, errors.Errorf( + "%s is registered under the wrong code: its driver answers to %q", code, answered) + } + l.ensureTransportLocked(definition.transport) + info := DriverInfo{ + Code: driver.GetProtocolCode(), + Name: driver.GetProtocolName(), + SupportsDiscovery: driver.SupportsDiscovery(), + } + l.registered[code] = info + l.drivers[code] = driver + return info, nil +} + +// ensureTransportLocked makes sure the transport a driver dials over is registered, and does +// nothing when it already is. Callers hold l.mu and have already established that the driver +// manager is spi.TransportAware. +// +// This is a safety net, not the normal path. As of plc4x v0.0.0-20260902093211, +// drivers.Register*Driver registers its driver's transports itself - two of them for the +// serial drivers, which also accept a socket - which is why the previous UI's +// tcpRegistered/udpRegistered latch never did what its name suggested: it suppressed only the +// browser's own duplicate call, while plc4x logged "Transport already registered" once per +// additional driver anyway. Checking the manager instead of registering unconditionally means +// a normal registration is silent, and a future plc4x that stops registering transports for us +// is still handled here rather than failing at connect time with "couldn't find transport tcp". +func (l *Live) ensureTransportLocked(transport liveTransport) { + definition := liveTransports[transport] + if l.transportsRegistered[transport] || + slices.Contains(l.driverManager.(spi.TransportAware).ListTransportNames(), definition.code) { + l.transportsRegistered[transport] = true + return + } + definition.register(l.driverManager) + l.transportsRegistered[transport] = true +} + +// Connections lists the open connections, ordered so the UI list is stable between renders. +func (l *Live) Connections() []ConnectionInfo { + l.mu.Lock() + defer l.mu.Unlock() + open := make([]ConnectionInfo, 0, len(l.connections)) + for _, held := range l.connections { + open = append(open, held.info) + } + slices.SortFunc(open, func(a, b ConnectionInfo) int { return strings.Compare(a.ID, b.ID) }) + return open +} + +// Connect opens a connection from a plc4x connection string. +// +// Everything that can be decided without touching the network is decided first - the string +// parses, it names a protocol, that protocol is registered, that connection is not already +// open - because each of those is a mistake the user can fix, and none of them is worth +// waiting a timeout to be told about. +func (l *Live) Connect(ctx context.Context, connectionString string) (ConnectionInfo, error) { + connectionString = strings.TrimSpace(connectionString) + connectionUrl, err := url.Parse(connectionString) + if err != nil { + return ConnectionInfo{}, errors.Wrapf(err, "can't parse connection url %s", connectionString) + } + if connectionUrl.Scheme == "" { + return ConnectionInfo{}, errors.Errorf( + "%q names no protocol, a connection string looks like c-bus://10.0.0.5:10001", connectionString) + } + code := canonicalLiveProtocol(connectionUrl.Scheme) + // The alias is rewritten in the string handed to plc4x, not merely resolved for the + // lookup here: plc4x's driver manager finds its driver by the connection string's own + // scheme, so leaving "bacnetip://..." alone fails with "couldn't find driver bacnetip" + // even with the driver registered - the very defect the aliases exist to remove. Spliced + // rather than re-rendered from the parsed url, so a query string is passed on byte for + // byte; url.Parse only lower-cases the scheme, so the lengths match. + if code != connectionUrl.Scheme { + connectionString = code + connectionString[len(connectionUrl.Scheme):] + } + id, transport := liveConnectionID(code, connectionUrl) + + l.mu.Lock() + if l.closed { + l.mu.Unlock() + return ConnectionInfo{}, errors.New("session is closed") + } + if _, exists := l.connections[id]; exists { + l.mu.Unlock() + return ConnectionInfo{}, errors.Errorf("%s already connected", id) + } + driver, registered := l.drivers[code] + l.mu.Unlock() + + if !registered { + return ConnectionInfo{}, errors.Errorf("%s can't connect: no driver registered for %s, register it first", id, code) + } + if transport == "" { + transport = driver.GetDefaultTransport() + } + + timeoutCtx, cancel := context.WithTimeout(ctx, l.options.Timeout) + defer cancel() + connection, err := l.driverManager.GetConnection(timeoutCtx, connectionString) + if err != nil { + return ConnectionInfo{}, errors.Wrapf(err, "%s can't connect", id) + } + + info := ConnectionInfo{ID: id, Protocol: driver.GetProtocolCode(), Transport: transport} + l.mu.Lock() + // The session may have been closed, or the same connection opened, while the driver was + // dialling. Dropping the winner's connection on the floor would leak a live socket, so the + // loser closes what it just opened. + _, raced := l.connections[id] + if l.closed || raced { + l.mu.Unlock() + if closeErr := connection.Close(); closeErr != nil { + return ConnectionInfo{}, errors.Wrapf(closeErr, "%s connected too late and could not be closed", id) + } + if raced { + return ConnectionInfo{}, errors.Errorf("%s already connected", id) + } + return ConnectionInfo{}, errors.New("session is closed") + } + l.connections[id] = liveConnection{info: info, connection: connection} + l.mu.Unlock() + return info, nil +} + +// Disconnect closes the connection with the given ID. +func (l *Live) Disconnect(id string) error { + l.mu.Lock() + held, exists := l.connections[id] + if !exists { + l.mu.Unlock() + return errors.Errorf("%s not connected", id) + } + delete(l.connections, id) + l.mu.Unlock() + + // Closed outside the lock: a driver's Close talks to the device and can take as long as + // the device likes, and holding l.mu through it would stall every pane that reads the + // connection list. + if err := held.connection.Close(); err != nil { + return errors.Wrapf(err, "%s can't close", id) + } + return nil +} + +// Discover searches for devices speaking a protocol. +// +// sink is called from the driver's own goroutine, once per device, before Discover returns. +func (l *Live) Discover(ctx context.Context, protocol string, sink func(DiscoveryItem)) error { + code := canonicalLiveProtocol(protocol) + + l.mu.Lock() + closed := l.closed + driver, registered := l.drivers[code] + l.mu.Unlock() + + if closed { + return errors.New("session is closed") + } + if !registered { + return errors.Errorf("%s not registered, register it first", code) + } + if !driver.SupportsDiscovery() { + return errors.Errorf("%s doesn't support discovery", code) + } + + timeoutCtx, cancel := context.WithTimeout(ctx, l.options.Timeout) + defer cancel() + return driver.Discover(timeoutCtx, func(item apiModel.PlcDiscoveryItem) { + sink(DiscoveryItem{ + ConnectionString: item.GetConnectionUrl(), + Protocol: item.GetProtocolCode(), + Transport: item.GetTransportCode(), + Name: item.GetName(), + }) + }) +} + +// Read reads tags from a connection. +func (l *Live) Read(ctx context.Context, connectionID string, tags []TagSpec) (ReadResult, error) { + connection, err := l.connection(connectionID) + if err != nil { + return ReadResult{}, err + } + if len(tags) == 0 { + return ReadResult{}, errors.New("a read request needs at least one tag") + } + + builder := connection.ReadRequestBuilder() + for _, tag := range tags { + builder = builder.AddTagAddress(tag.Name, tag.Address) + } + request, err := builder.Build() + if err != nil { + return ReadResult{}, errors.Wrapf(err, "%s can't read", connectionID) + } + + timeoutCtx, cancel := context.WithTimeout(ctx, l.options.Timeout) + defer cancel() + started := l.options.Now() + result, err := awaitResult(timeoutCtx, request.Execute(timeoutCtx), connectionID, "read", l.options.Timeout) + if err != nil { + return ReadResult{}, err + } + if err := result.GetErr(); err != nil { + return ReadResult{}, errors.Wrapf(err, "%s error reading", connectionID) + } + response := result.GetResponse() + if response == nil { + return ReadResult{}, errors.Errorf("%s answered a read with neither a response nor an error", connectionID) + } + + // Iterated in request order rather than over response.GetTagNames(): plc4x returns those + // out of a map, and a results table that reshuffles itself between reads is unreadable. + out := ReadResult{Tags: make([]TagResult, 0, len(tags))} + for _, tag := range tags { + value := response.GetValue(tag.Name) + out.Tags = append(out.Tags, TagResult{ + Name: tag.Name, + Address: tag.Address, + DataType: plcValueType(value), + Value: plcValueString(value), + Code: response.GetResponseCode(tag.Name).GetName(), + }) + } + out.Duration = l.options.Now().Sub(started) + return out, nil +} + +// Write writes tags to a connection. +func (l *Live) Write(ctx context.Context, connectionID string, tags []TagSpec) (WriteResult, error) { + connection, err := l.connection(connectionID) + if err != nil { + return WriteResult{}, err + } + if len(tags) == 0 { + return WriteResult{}, errors.New("a write request needs at least one tag") + } + + builder := connection.WriteRequestBuilder() + for _, tag := range tags { + // The value goes over as the string the user typed. Each driver's tag handler coerces + // it to the tag's own type, which is the only place that knows what the type is. + builder = builder.AddTagAddress(tag.Name, tag.Address, tag.Value) + } + request, err := builder.Build() + if err != nil { + return WriteResult{}, errors.Wrapf(err, "%s can't write", connectionID) + } + + timeoutCtx, cancel := context.WithTimeout(ctx, l.options.Timeout) + defer cancel() + started := l.options.Now() + result, err := awaitResult(timeoutCtx, request.Execute(timeoutCtx), connectionID, "write", l.options.Timeout) + if err != nil { + return WriteResult{}, err + } + if err := result.GetErr(); err != nil { + return WriteResult{}, errors.Wrapf(err, "%s error writing", connectionID) + } + response := result.GetResponse() + if response == nil { + return WriteResult{}, errors.Errorf("%s answered a write with neither a response nor an error", connectionID) + } + + out := WriteResult{Tags: make([]TagResult, 0, len(tags))} + for _, tag := range tags { + out.Tags = append(out.Tags, TagResult{ + Name: tag.Name, + Address: tag.Address, + // A write response carries no value back, so the value shown is the one asked for. + Value: tag.Value, + Code: response.GetResponseCode(tag.Name).GetName(), + }) + } + out.Duration = l.options.Now().Sub(started) + return out, nil +} + +// Browse enumerates the tags a connection exposes. +func (l *Live) Browse(ctx context.Context, connectionID, query string) (BrowseResult, error) { + connection, err := l.connection(connectionID) + if err != nil { + return BrowseResult{}, err + } + + query = strings.TrimSpace(query) + if query == "" { + // plc4x has no universal "everything" query - each driver defines its own address + // syntax - so an empty query goes over as the conventional wildcard and the driver + // decides. A driver that rejects it says so in its own words, which is more use than + // a refusal invented here. + query = "*" + } + + request, err := connection.BrowseRequestBuilder().AddQuery(liveBrowseQueryName, query).Build() + if err != nil { + return BrowseResult{}, errors.Wrapf(err, "%s can't browse %s", connectionID, query) + } + + timeoutCtx, cancel := context.WithTimeout(ctx, l.options.Timeout) + defer cancel() + started := l.options.Now() + result, err := awaitResult(timeoutCtx, request.Execute(timeoutCtx), connectionID, "browse", l.options.Timeout) + if err != nil { + return BrowseResult{}, err + } + if err := result.GetErr(); err != nil { + return BrowseResult{}, errors.Wrapf(err, "%s error browsing", connectionID) + } + response := result.GetResponse() + if response == nil { + return BrowseResult{}, errors.Errorf("%s answered a browse with neither a response nor an error", connectionID) + } + if code := response.GetResponseCode(liveBrowseQueryName); code != apiModel.PlcResponseCode_OK { + return BrowseResult{}, errors.Errorf("%s refused the browse query %s: %s", connectionID, query, code.GetName()) + } + + out := BrowseResult{} + for _, item := range response.GetQueryResults(liveBrowseQueryName) { + out.Items = appendBrowseItems(out.Items, item, browseFlattenDepth) + } + out.Duration = l.options.Now().Sub(started) + return out, nil +} + +// Subscribe subscribes to tags, streaming events until the context is cancelled. +func (l *Live) Subscribe(ctx context.Context, connectionID string, tags []TagSpec) (<-chan Event, error) { + connection, err := l.connection(connectionID) + if err != nil { + return nil, err + } + if len(tags) == 0 { + return nil, errors.New("a subscription needs at least one tag") + } + + // received is written by the driver's consumer goroutines and read by the forwarder below. + // It is deliberately never closed: only the forwarder closes a channel, and only the one + // it owns. Sharing one channel between a driver-owned producer and a cancellation-owned + // closer is how this repository last acquired a send-on-closed-channel race, in the packet + // mapper. + received := make(chan Event) + consumer := func(tag TagSpec) apiModel.PlcSubscriptionEventConsumer { + return func(event apiModel.PlcSubscriptionEvent) { + mapped := l.subscriptionEvent(connectionID, tag, event) + select { + case received <- mapped: + case <-ctx.Done(): + // Dropped rather than blocked. A driver keeps calling its consumers after the + // UI has stopped listening, and a consumer parked on a send holds a driver + // goroutine for the rest of the process's life. + } + } + } + + builder := connection.SubscriptionRequestBuilder() + for _, tag := range tags { + // Event subscriptions, as the previous UI used: they are what the monitor-style + // addresses the browser suggests (c-bus salmonitor/mmimonitor) actually are. + builder = builder.AddEventTagAddress(tag.Name, tag.Address) + builder = builder.AddPreRegisteredConsumer(tag.Name, consumer(tag)) + } + request, err := builder.Build() + if err != nil { + return nil, errors.Wrapf(err, "%s can't subscribe", connectionID) + } + + timeoutCtx, cancel := context.WithTimeout(ctx, l.options.Timeout) + defer cancel() + result, err := awaitResult(timeoutCtx, request.Execute(timeoutCtx), connectionID, "subscribe", l.options.Timeout) + if err != nil { + return nil, err + } + if err := result.GetErr(); err != nil { + return nil, errors.Wrapf(err, "%s can't subscribe", connectionID) + } + response := result.GetResponse() + if response == nil { + return nil, errors.Errorf("%s answered a subscription with neither a response nor an error", connectionID) + } + for _, tag := range tags { + if code := response.GetResponseCode(tag.Name); code != apiModel.PlcResponseCode_OK { + return nil, errors.Errorf("%s refused the subscription to %s: %s", connectionID, tag.Address, code.GetName()) + } + } + + events := make(chan Event) + handles := response.GetSubscriptionHandles() + go l.forwardSubscription(ctx, connection, handles, received, events) + return events, nil +} + +// forwardSubscription owns the event channel: it is the only writer and the only closer, which +// is what keeps a driver's consumer from sending on a closed channel. +// +// On cancellation it also unsubscribes. Without that the driver goes on producing events that +// nothing will ever read, which is a slow leak rather than a visible fault. +func (l *Live) forwardSubscription( + ctx context.Context, + connection plc4go.PlcConnection, + handles []apiModel.PlcSubscriptionHandle, + received <-chan Event, + events chan<- Event, +) { + defer close(events) + defer l.unsubscribe(ctx, connection, handles) + for { + select { + case <-ctx.Done(): + return + case event := <-received: + select { + case events <- event: + case <-ctx.Done(): + return + } + } + } +} + +// unsubscribe tells the device to stop sending. It runs after ctx is already done, so it needs +// a context of its own; the cancelled one would be refused immediately. +func (l *Live) unsubscribe(ctx context.Context, connection plc4go.PlcConnection, handles []apiModel.PlcSubscriptionHandle) { + if len(handles) == 0 { + return + } + request, err := connection.UnsubscriptionRequestBuilder().AddHandles(handles...).Build() + if err != nil { + return + } + timeoutCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), l.options.Timeout) + defer cancel() + select { + case <-request.Execute(timeoutCtx): + case <-timeoutCtx.Done(): + } +} + +// subscriptionEvent maps one plc4x subscription event onto the UI's Event. +func (l *Live) subscriptionEvent(connectionID string, tag TagSpec, event apiModel.PlcSubscriptionEvent) Event { + mapped := Event{ + Kind: EventSubscribe, + Connection: connectionID, + Received: l.options.Now(), + } + // Sorted, because plc4x hands the names out of a map and the message pane renders them in + // the order given. + names := slices.Sorted(slices.Values(event.GetTagNames())) + for _, name := range names { + address := event.GetAddress(name) + if address == "" { + // Not every event is directly addressable; some only carry the source that sent + // them, and some carry neither, in which case the subscribed address is the best + // available label. + address = event.GetSource(name) + } + if address == "" { + address = tag.Address + } + value := event.GetValue(name) + mapped.Tags = append(mapped.Tags, TagResult{ + Name: name, + Address: address, + DataType: plcValueType(value), + Value: plcValueString(value), + Code: event.GetResponseCode(name).GetName(), + }) + } + return mapped +} + +// Close releases every connection, and the driver manager when this session created it. +func (l *Live) Close() error { + l.mu.Lock() + if l.closed { + l.mu.Unlock() + return nil + } + l.closed = true + held := slices.Collect(maps.Values(l.connections)) + l.connections = map[string]liveConnection{} + l.registered = map[string]DriverInfo{} + l.drivers = map[string]plc4go.PlcDriver{} + l.mu.Unlock() + + // Sorted so a multi-connection failure reports the same way twice. + slices.SortFunc(held, func(a, b liveConnection) int { return strings.Compare(a.info.ID, b.info.ID) }) + var closeErrors []error + for _, connection := range held { + if err := connection.connection.Close(); err != nil { + closeErrors = append(closeErrors, errors.Wrapf(err, "%s can't close", connection.info.ID)) + } + } + if l.ownsDriverManager { + if err := l.driverManager.Close(); err != nil { + closeErrors = append(closeErrors, errors.Wrap(err, "can't close the driver manager")) + } + } + return errors.Join(closeErrors...) +} + +// connection resolves a connection ID, reporting the two ways it can fail to resolve. +func (l *Live) connection(id string) (plc4go.PlcConnection, error) { + l.mu.Lock() + defer l.mu.Unlock() + if l.closed { + return nil, errors.New("session is closed") + } + held, exists := l.connections[id] + if !exists { + return nil, errors.Errorf("%s not connected", id) + } + return held.connection, nil +} + +// awaitResult waits for a plc4x request result under a context. +// +// plc4x's request channels are buffered, so abandoning one on a timeout costs nothing; what it +// buys is that a driver which fails to honour its own context cannot hold the UI. That is not +// hypothetical - a driver waiting on a handshake was measured blocking for a full minute. +func awaitResult[T any](ctx context.Context, results <-chan T, connectionID, operation string, timeout time.Duration) (T, error) { + select { + case result := <-results: + return result, nil + case <-ctx.Done(): + var zero T + return zero, errors.Wrapf(ctx.Err(), "%s gave up on the %s after %s", connectionID, operation, timeout) + } +} + +// canonicalLiveProtocol resolves an alias to the protocol code its driver answers to. +func canonicalLiveProtocol(protocol string) string { + protocol = strings.ToLower(strings.TrimSpace(protocol)) + if canonical, aliased := liveProtocolAliases[protocol]; aliased { + return canonical + } + return protocol +} + +// liveConnectionID derives the canonical "code://device" identity of a connection string, +// together with the transport code when the string names one. code is the canonical protocol +// code, so that an alias and the code it resolves to are one connection rather than two. +// +// plc4x connection strings come in two shapes: "c-bus://host:port", and the transport-explicit +// "c-bus:tcp://host:port", where net/url parses everything after the first colon as opaque +// data and leaves Host empty. The previous UI derived the ID from Host alone, so every +// transport-explicit connection collapsed onto the single ID "c-bus://" and the second one +// opened was rejected as already connected. +// +// There is a third shape, and it is the one the serial drivers brought: a serial connection +// string has no host at all, and plc4x reads the port name out of url.Path alone (see +// spi/transports/serial Transport.CreateTransportInstanceForLocalAddress). The path therefore +// belongs to the identity exactly when there is no host to identify the device by. Keying on +// the host alone would collapse "modbus-rtu:///dev/ttyUSB0" and "modbus-rtu:///dev/ttyUSB1" +// onto the one ID "modbus-rtu://" - the same defect as above wearing the serial drivers' +// clothes: only one port could be open at a time, and the connections pane would name the one +// that was with no hint of which device it is. A path alongside a host stays out of the ID: no +// socket transport reads it, so one device would otherwise answer to several identities. +func liveConnectionID(code string, connectionUrl *url.URL) (id string, transport string) { + host, path := connectionUrl.Host, connectionUrl.Path + if connectionUrl.Opaque != "" { + if opaque, err := url.Parse(connectionUrl.Opaque); err == nil { + transport = opaque.Scheme + host, path = opaque.Host, opaque.Path + } + } + if host == "" { + host = path + } + return code + "://" + host, transport +} + +// appendBrowseItems flattens a browse item and its children into the result list. +// +// plc4x returns a tree; the browse pane is a table. Flattening keeps the whole address space +// reachable without the UI having to learn a second shape. +func appendBrowseItems(items []BrowseItem, item apiModel.PlcBrowseItem, depth int) []BrowseItem { + if item == nil || depth <= 0 { + return items + } + mapped := BrowseItem{ + Name: item.GetName(), + Readable: item.IsReadable(), + Writable: item.IsWritable(), + Subscribable: item.IsSubscribable(), + } + if tag := item.GetTag(); tag != nil { + mapped.Address = tag.GetAddressString() + mapped.DataType = tag.GetValueType().String() + } + items = append(items, mapped) + + children := item.GetChildren() + // Sorted, because the children arrive in a map and a browse listing that reorders itself + // between two identical queries is not a listing. + for _, name := range slices.Sorted(maps.Keys(children)) { + items = appendBrowseItems(items, children[name], depth-1) + } + return items +} + +// plcValueString renders a plc4x value for display, tolerating the nil that comes back with a +// failed tag. +func plcValueString(value values.PlcValue) string { + if value == nil || value.IsNull() { + return "" + } + return value.String() +} + +// plcValueType names a plc4x value's type, tolerating the nil that comes back with a failed +// tag. +func plcValueType(value values.PlcValue) string { + if value == nil { + return "" + } + return value.GetPlcValueType().String() +} + +// Compile-time proof that Live satisfies the seam. +var _ Session = (*Live)(nil) diff --git a/plc4go/tools/internal/plcsession/live_test.go b/plc4go/tools/internal/plcsession/live_test.go new file mode 100644 index 000000000..bbe6a618f --- /dev/null +++ b/plc4go/tools/internal/plcsession/live_test.go @@ -0,0 +1,727 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package plcsession_test + +import ( + "context" + "maps" + "slices" + "testing" + "time" + + plc4go "github.com/apache/plc4x/plc4go/pkg/api" + "github.com/apache/plc4x/plc4go/pkg/api/config" + "github.com/apache/plc4x/plc4go/pkg/api/drivers" + apiModel "github.com/apache/plc4x/plc4go/pkg/api/model" + "github.com/apache/plc4x/plc4go/spi" + spiTransports "github.com/apache/plc4x/plc4go/spi/transports" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/plcsession" +) + +// Compile-time proof that Live satisfies the seam the user interface talks to. +var _ plcsession.Session = (*plcsession.Live)(nil) + +// Everything below runs without a PLC and without touching the network. Registering a driver +// only builds objects, and every Connect exercised here is rejected before the driver dials, +// which is exactly the behaviour worth pinning: the operations that DO dial were measured +// blocking for a full minute, so a test must never reach one. + +// liveSession returns a live session over a driver manager the test can inspect. +func liveSession(t *testing.T, timeout time.Duration) (*plcsession.Live, plc4go.PlcDriverManager) { + t.Helper() + manager := plc4go.NewPlcDriverManager() + live := plcsession.NewLive(plcsession.LiveOptions{DriverManager: manager, Timeout: timeout}) + t.Cleanup(func() { + assert.NoError(t, live.Close()) + assert.NoError(t, manager.Close()) + }) + return live, manager +} + +func TestLiveProtocolsAreSortedAndNotEmpty(t *testing.T) { + protocols := plcsession.NewLive(plcsession.LiveOptions{}).Protocols() + require.NotEmpty(t, protocols) + assert.True(t, slices.IsSorted(protocols), "the protocol list is offered as completions, so it must be stable") +} + +// TestLiveProtocolsCannotBeMutatedByTheCaller matters because the UI hands this slice to a +// completion widget, which is free to sort or truncate it in place. +func TestLiveProtocolsCannotBeMutatedByTheCaller(t *testing.T) { + live := plcsession.NewLive(plcsession.LiveOptions{}) + first := live.Protocols() + require.NotEmpty(t, first) + first[0] = "clobbered" + assert.NotEqual(t, "clobbered", live.Protocols()[0], "Protocols must not expose shared state") +} + +// TestLiveEveryAdvertisedProtocolRegistersUnderItsOwnCode is the regression test for the defect +// this implementation replaces: the previous UI advertised "bacnetip", but plc4x registers that +// driver as "bacnet-ip", so the advertised name could never appear in a working connection +// string. Any future drift between the advertised list and the drivers' real codes fails here. +func TestLiveEveryAdvertisedProtocolRegistersUnderItsOwnCode(t *testing.T) { + for _, protocol := range plcsession.NewLive(plcsession.LiveOptions{}).Protocols() { + t.Run(protocol, func(t *testing.T) { + live, _ := liveSession(t, time.Second) + info, err := live.RegisterDriver(protocol) + require.NoError(t, err) + assert.Equal(t, protocol, info.Code, + "an advertised protocol must be the code its driver answers to") + assert.NotEmpty(t, info.Name, "the drivers pane shows the name") + }) + } +} + +// TestLiveDriverInfoIsReadOffTheDriver pins that the reported metadata comes from plc4x rather +// than from a table maintained alongside it, which is the mistake that produced the +// bacnetip/bacnet-ip mismatch in the first place. +func TestLiveDriverInfoIsReadOffTheDriver(t *testing.T) { + live, manager := liveSession(t, time.Second) + for _, protocol := range live.Protocols() { + t.Run(protocol, func(t *testing.T) { + info, err := live.RegisterDriver(protocol) + require.NoError(t, err) + + driver, err := manager.GetDriver(info.Code) + require.NoError(t, err, "the driver must be reachable in the manager under the reported code") + assert.Equal(t, driver.GetProtocolCode(), info.Code) + assert.Equal(t, driver.GetProtocolName(), info.Name) + assert.Equal(t, driver.SupportsDiscovery(), info.SupportsDiscovery) + }) + } +} + +func TestLiveRegisterDriverRejectsASecondRegistration(t *testing.T) { + live, _ := liveSession(t, time.Second) + _, err := live.RegisterDriver("c-bus") + require.NoError(t, err) + + _, err = live.RegisterDriver("c-bus") + require.Error(t, err, "a repeated registration is a typo worth reporting, not a no-op") + assert.Contains(t, err.Error(), "already registered") +} + +// TestLiveRegisterDriverAcceptsTheBacnetipAlias keeps the spelling the previous UI, its saved +// configuration files and its auto-register list all used, without letting two spellings become +// two registrations. +func TestLiveRegisterDriverAcceptsTheBacnetipAlias(t *testing.T) { + for _, alias := range []string{"bacnet", "bacnetip", "BACnetIP", " bacnetip "} { + t.Run(alias, func(t *testing.T) { + live, _ := liveSession(t, time.Second) + info, err := live.RegisterDriver(alias) + require.NoError(t, err) + assert.Equal(t, "bacnet-ip", info.Code) + + _, err = live.RegisterDriver("bacnet-ip") + assert.Error(t, err, "an alias and its canonical code must be one registration") + }) + } +} + +// TestLiveRegisterDriverRejectsAnUnknownProtocolActionably uses bare "modbus" deliberately: +// plc4x has three Modbus drivers and no driver answering to "modbus", so it is the plausible +// thing to type and the message has to be the thing that says which three exist. +func TestLiveRegisterDriverRejectsAnUnknownProtocolActionably(t *testing.T) { + live, _ := liveSession(t, time.Second) + _, err := live.RegisterDriver("modbus") + require.Error(t, err) + assert.Contains(t, err.Error(), `"modbus"`, "the message should quote what was asked for") + for _, protocol := range live.Protocols() { + assert.Contains(t, err.Error(), protocol, "the message should list what can be registered instead") + } +} + +// TestLiveSupportsEveryPublicPlc4xDriver pins the protocol list whole: the exact contents, the +// exact order, and the count. +// +// The browser supported five of plc4x's sixteen public drivers, so eleven protocols - every +// Modbus flavour among them - could not be reached from it at all. Pinning the count as well as +// the contents means a plc4x release that adds a driver fails here, rather than quietly +// producing a twelfth unreachable protocol. +func TestLiveSupportsEveryPublicPlc4xDriver(t *testing.T) { + protocols := plcsession.NewLive(plcsession.LiveOptions{}).Protocols() + require.Len(t, protocols, 16, "every public plc4go driver must be offered") + assert.Equal(t, []string{ + "ab-eth", + "ads", + "bacnet-ip", + "c-bus", + "eip", + "firmata", + "iec-60870-5-104", + "knxnet-ip", + "logix", + "modbus-ascii", + "modbus-rtu", + "modbus-tcp", + "opcua", + "s7", + "slmp", + "umas", + }, protocols, "these are the codes the drivers themselves answer to, in the order the UI lists them") +} + +// TestLiveRegisteringADriverRegistersTheTransportItDialsOver checks the session's own idea of +// which transport a protocol needs against the driver's, for every protocol. +// +// The session has to state the transport itself, because ensureTransportLocked needs to know +// which one to have ready before there is a driver to ask. A disagreement between the two is +// invisible until someone connects, and then it surfaces as "couldn't find transport serial", +// which reads as a plc4x fault rather than as this table being wrong. +func TestLiveRegisteringADriverRegistersTheTransportItDialsOver(t *testing.T) { + for _, protocol := range plcsession.NewLive(plcsession.LiveOptions{}).Protocols() { + t.Run(protocol, func(t *testing.T) { + live, manager := liveSession(t, time.Second) + info, err := live.RegisterDriver(protocol) + require.NoError(t, err) + + driver, err := manager.GetDriver(info.Code) + require.NoError(t, err) + dialsOver := driver.GetDefaultTransport() + require.NotEmpty(t, dialsOver, + "plc4x refuses a connection string whose driver has no default transport") + assert.Contains(t, manager.(spi.TransportAware).ListTransportNames(), dialsOver, + "%s dials over %s, so that transport has to be registered", protocol, dialsOver) + }) + } +} + +// TestLiveRegisteringADriverRegistersItsNamedTransport spells the three transports out by name +// rather than reading them off the drivers, so that the serial case is stated somewhere a +// reader can see it. serial is the transport the session did not have: without it firmata, +// modbus-rtu and modbus-ascii cannot be connected to at all, whatever the protocol list says. +func TestLiveRegisteringADriverRegistersItsNamedTransport(t *testing.T) { + for protocol, transport := range map[string]string{ + "firmata": "serial", + "modbus-rtu": "serial", + "modbus-ascii": "serial", + "modbus-tcp": "tcp", + "knxnet-ip": "udp", + } { + t.Run(protocol, func(t *testing.T) { + live, manager := liveSession(t, time.Second) + _, err := live.RegisterDriver(protocol) + require.NoError(t, err) + assert.Contains(t, manager.(spi.TransportAware).ListTransportNames(), transport) + }) + } +} + +// TestLiveDiscoverySupportIsTheDriversOwnAnswer pins discovery support to what each driver says +// and to the values the drivers actually give, both answers included. +// +// Two assertions, because either alone can be passed by a mistake: comparing only against the +// driver would still pass if every driver were asked the wrong question, and pinning only the +// literals would still pass if the answer were restated in a table here instead of read from +// plc4x. The UI greys out `discover` for a driver that reports false, so a guess costs the user +// either a missing feature or an error at the prompt. +func TestLiveDiscoverySupportIsTheDriversOwnAnswer(t *testing.T) { + discovery := map[string]bool{ + "ads": true, + "bacnet-ip": true, + "eip": true, + "knxnet-ip": true, + "logix": true, + "ab-eth": false, + "c-bus": false, + "firmata": false, + "iec-60870-5-104": false, + "modbus-ascii": false, + "modbus-rtu": false, + "modbus-tcp": false, + "opcua": false, + "s7": false, + "slmp": false, + "umas": false, + } + // Every protocol, so a driver added to the registry cannot slip in without its discovery + // answer being stated - the tab strip greys `discover` out on this flag alone. + assert.ElementsMatch(t, plcsession.NewLive(plcsession.LiveOptions{}).Protocols(), + slices.Collect(maps.Keys(discovery))) + + for protocol, discovers := range discovery { + t.Run(protocol, func(t *testing.T) { + live, manager := liveSession(t, time.Second) + info, err := live.RegisterDriver(protocol) + require.NoError(t, err) + + driver, err := manager.GetDriver(info.Code) + require.NoError(t, err) + assert.Equal(t, driver.SupportsDiscovery(), info.SupportsDiscovery, + "discovery support must be the driver's answer, not a guess") + assert.Equal(t, discovers, info.SupportsDiscovery) + }) + } +} + +func TestLiveDriversAreSortedForAStableList(t *testing.T) { + live, _ := liveSession(t, time.Second) + for _, protocol := range []string{"s7", "ads", "c-bus"} { + _, err := live.RegisterDriver(protocol) + require.NoError(t, err) + } + + codes := make([]string, 0, 3) + for _, info := range live.Drivers() { + codes = append(codes, info.Code) + } + assert.Equal(t, []string{"ads", "c-bus", "s7"}, codes, "a UI list must not reorder between renders") +} + +// countingDriverManager counts transport registrations, so a test can see how many of them a +// driver registration causes. That number is what the browser's console pane pays for: plc4x +// logs "Transport already registered" at Warn level for every registration after the first, and +// the browser puts that log on screen, where a warning for a perfectly ordinary second driver +// reads as a fault. +type countingDriverManager struct { + plc4go.PlcDriverManager + registrations map[string]int +} + +func newCountingDriverManager() *countingDriverManager { + return &countingDriverManager{ + PlcDriverManager: plc4go.NewPlcDriverManager(), + registrations: map[string]int{}, + } +} + +func (c *countingDriverManager) transportAware() spi.TransportAware { + return c.PlcDriverManager.(spi.TransportAware) +} + +// total is how many transport registrations have been attempted, of any transport. +func (c *countingDriverManager) total() int { + sum := 0 + for _, count := range c.registrations { + sum += count + } + return sum +} + +func (c *countingDriverManager) RegisterTransport(transport spiTransports.Transport) { + c.registrations[transport.GetTransportCode()]++ + c.transportAware().RegisterTransport(transport) +} + +func (c *countingDriverManager) ListTransportNames() []string { + return c.transportAware().ListTransportNames() +} + +func (c *countingDriverManager) GetTransport(name, connectionString string, options map[string][]string) (spiTransports.Transport, error) { + return c.transportAware().GetTransport(name, connectionString, options) +} + +// TestLiveAddsNoTransportRegistrationOfItsOwn pins the transport behaviour precisely: going +// through the session must cost exactly the registrations plc4x would have made anyway. +// +// plc4x's drivers.Register*Driver already registers the transports its driver needs, so a +// session that also registers one produces a redundant registration - and with it the warning +// line the previous UI's latch was there to avoid and never did avoid: plc4x logs "Transport +// already registered" at Warn level, and the browser puts that log on screen, where a warning +// for a perfectly ordinary second driver reads as a fault. +// +// The baseline is measured rather than stated, because the number is not one per driver and +// not knowable from here. RegisterFirmataDriver, RegisterModbusRtuDriver and +// RegisterModbusAsciiDriver each register TWO transports - serial and tcp - since those +// protocols are commonly tunnelled over a socket. Comparing against plc4x itself keeps this +// test honest whatever plc4x decides to register. +func TestLiveAddsNoTransportRegistrationOfItsOwn(t *testing.T) { + // Three TCP drivers, one UDP and one serial one, so every transport is covered and so are + // both the first-registration and the repeat cases. + plc4xRegistrations := map[string]func(plc4go.PlcDriverManager, ...config.WithOption) plc4go.PlcDriver{ + "ads": drivers.RegisterAdsDriver, + "c-bus": drivers.RegisterCBusDriver, + "s7": drivers.RegisterS7Driver, + "bacnet-ip": drivers.RegisterBacnetDriver, + "firmata": drivers.RegisterFirmataDriver, + } + + manager := newCountingDriverManager() + live := plcsession.NewLive(plcsession.LiveOptions{DriverManager: manager, Timeout: time.Second}) + t.Cleanup(func() { + assert.NoError(t, live.Close()) + assert.NoError(t, manager.Close()) + }) + + for protocol, registerDirectly := range plc4xRegistrations { + // A manager of its own, so the baseline is what plc4x costs for this driver alone. + baseline := newCountingDriverManager() + registerDirectly(baseline) + require.NoError(t, baseline.Close()) + + before := manager.total() + _, err := live.RegisterDriver(protocol) + require.NoError(t, err, protocol) + assert.Equal(t, baseline.total(), manager.total()-before, + "registering %s through the session must cost the same registrations as plc4x's own call, and every extra one is a warning on screen", + protocol) + } + + assert.Equal(t, []string{"serial", "tcp", "udp"}, slices.Sorted(slices.Values(manager.ListTransportNames())), + "the manager must end up holding exactly the three transports these five drivers dial over") +} + +// forgetfulDriverManager accepts transport registrations, records the order they arrived in, +// and reports that it holds none. +// +// It stands in for a future plc4x whose drivers.Register*Driver no longer registers transports +// itself - the case ensureTransportLocked exists for, and the only case in which the session's +// own choice of transport is observable. Today plc4x registers a driver's transports before the +// session gets a chance to, so a wrong entry in the session's table would be masked. +type forgetfulDriverManager struct { + plc4go.PlcDriverManager + registered []string +} + +func (f *forgetfulDriverManager) RegisterTransport(transport spiTransports.Transport) { + f.registered = append(f.registered, transport.GetTransportCode()) +} + +// ListTransportNames reports nothing, whatever has been registered. That is the forgetting. +func (f *forgetfulDriverManager) ListTransportNames() []string { return nil } + +func (f *forgetfulDriverManager) GetTransport(string, string, map[string][]string) (spiTransports.Transport, error) { + return nil, assert.AnError +} + +// TestLiveRegistersTheTransportADriverDialsOverWhenPlc4xHasNot pins the session's own table of +// which transport each protocol needs, for every protocol. +// +// The last registration is the session's: RegisterDriver hands the driver to plc4x first and +// calls ensureTransportLocked afterwards. So the last code recorded here is the session's +// answer to "what does this driver dial over", and it has to be the driver's own answer. +// Without a driver manager that forgets, this is untestable - a serial driver's serial +// transport is registered by plc4x itself, so the session claiming tcp instead would still +// leave "serial" in ListTransportNames and connect perfectly well until the day plc4x stops. +func TestLiveRegistersTheTransportADriverDialsOverWhenPlc4xHasNot(t *testing.T) { + for _, protocol := range plcsession.NewLive(plcsession.LiveOptions{}).Protocols() { + t.Run(protocol, func(t *testing.T) { + manager := &forgetfulDriverManager{PlcDriverManager: plc4go.NewPlcDriverManager()} + live := plcsession.NewLive(plcsession.LiveOptions{DriverManager: manager, Timeout: time.Second}) + t.Cleanup(func() { + assert.NoError(t, live.Close()) + assert.NoError(t, manager.Close()) + }) + + info, err := live.RegisterDriver(protocol) + require.NoError(t, err) + driver, err := manager.GetDriver(info.Code) + require.NoError(t, err) + + require.NotEmpty(t, manager.registered, "the session must register the transport itself") + assert.Equal(t, driver.GetDefaultTransport(), manager.registered[len(manager.registered)-1], + "the session must register the transport %s actually dials over", protocol) + }) + } +} + +// bareDriverManager implements only PlcDriverManager, without spi.TransportAware. +type bareDriverManager struct{} + +func (bareDriverManager) Close() error { return nil } +func (bareDriverManager) RegisterDriver(plc4go.PlcDriver) {} +func (bareDriverManager) ListDriverNames() []string { return nil } +func (bareDriverManager) GetDriver(string) (plc4go.PlcDriver, error) { + return nil, assert.AnError +} + +func (bareDriverManager) GetConnection(context.Context, string) (plc4go.PlcConnection, error) { + return nil, assert.AnError +} + +func (bareDriverManager) Discover(context.Context, func(apiModel.PlcDiscoveryItem), ...plc4go.WithDiscoveryOption) error { + return assert.AnError +} + +// TestLiveRegisterDriverReportsAManagerThatCannotCarryTransports exists because plc4x's +// transport helpers type-assert the driver manager without checking, so the unguarded path is a +// panic inside a UI command goroutine rather than an error the UI can show. +func TestLiveRegisterDriverReportsAManagerThatCannotCarryTransports(t *testing.T) { + live := plcsession.NewLive(plcsession.LiveOptions{DriverManager: bareDriverManager{}, Timeout: time.Second}) + t.Cleanup(func() { assert.NoError(t, live.Close()) }) + + _, err := live.RegisterDriver("c-bus") + require.Error(t, err, "this must be an error, not a panic") + assert.Contains(t, err.Error(), "transport") +} + +// TestLiveConnectRejectsABadConnectionStringWithoutWaiting is the behaviour that keeps a typo +// from looking like a hang: the whole request is decided from the string, before any driver is +// given the chance to spend the timeout on it. +func TestLiveConnectRejectsABadConnectionStringWithoutWaiting(t *testing.T) { + // Long enough that a timeout-driven failure would be unmistakable in the elapsed time. + const timeout = 30 * time.Second + for name, connectionString := range map[string]string{ + "empty": "", + "blank": " ", + "no scheme": "10.0.0.5:10001", + "missing scheme": "://10.0.0.5:10001", + "bad percent escape": "c-bus://10.0.0.5:10001/%zz", + "control character": "c-bus://10.0.0.5:1\x7f", + } { + t.Run(name, func(t *testing.T) { + live, _ := liveSession(t, timeout) + _, err := live.RegisterDriver("c-bus") + require.NoError(t, err) + + started := time.Now() + _, err = live.Connect(t.Context(), connectionString) + elapsed := time.Since(started) + + require.Error(t, err) + assert.NotContains(t, err.Error(), "gave up", "this must fail on inspection, not on the timeout") + assert.NotContains(t, err.Error(), "deadline exceeded") + assert.Less(t, elapsed, time.Second, + "rejecting %q took %s of a %s timeout, so it went to the driver", connectionString, elapsed, timeout) + }) + } +} + +// TestLiveConnectReportsAnUnregisteredProtocolRatherThanTimingOut also pins the connection ID: +// the message quotes the canonical "scheme://host", with the path and query stripped, which is +// the identity every later command addresses the connection by. +func TestLiveConnectReportsAnUnregisteredProtocolRatherThanTimingOut(t *testing.T) { + live, _ := liveSession(t, 30*time.Second) + + started := time.Now() + _, err := live.Connect(t.Context(), "c-bus://10.0.0.5:10001/ignored?also=ignored") + elapsed := time.Since(started) + + require.Error(t, err) + assert.Contains(t, err.Error(), "c-bus://10.0.0.5:10001", "the error should name the canonical connection id") + assert.NotContains(t, err.Error(), "ignored", "the path and query are not part of the identity") + assert.Contains(t, err.Error(), "no driver registered") + assert.Less(t, elapsed, time.Second, "an unregistered driver is knowable without dialling") +} + +// TestLiveConnectIdentifiesTransportExplicitConnectionsByTheirHost covers the second plc4x +// connection-string shape, "c-bus:tcp://host", which net/url leaves in Opaque with an empty +// Host. Deriving the ID from Host alone - what the previous UI did - collapsed every such +// connection onto the single ID "c-bus://". +func TestLiveConnectIdentifiesTransportExplicitConnectionsByTheirHost(t *testing.T) { + live, _ := liveSession(t, 30*time.Second) + for connectionString, wantID := range map[string]string{ + "c-bus:tcp://10.0.0.5:10001": "c-bus://10.0.0.5:10001", + "c-bus:tcp://10.0.0.6:10001": "c-bus://10.0.0.6:10001", + } { + t.Run(connectionString, func(t *testing.T) { + _, err := live.Connect(t.Context(), connectionString) + require.Error(t, err) + assert.Contains(t, err.Error(), wantID) + }) + } +} + +// TestLiveConnectIdentifiesSerialConnectionsByTheirDevice covers the third connection-string +// shape, the one the serial drivers brought: "modbus-rtu:///dev/ttyUSB0" has no host at all, +// and plc4x reads the port name out of url.Path. +// +// Keyed on the host alone every serial port collapses onto the single ID "modbus-rtu://", so +// two ports could not be open at once - the second is refused as already connected - and the +// connections pane would name the one that was without saying which device it is. Both the +// plain and the transport-explicit spelling are covered, because the path arrives in a +// different field in each. +func TestLiveConnectIdentifiesSerialConnectionsByTheirDevice(t *testing.T) { + live, _ := liveSession(t, 30*time.Second) + for connectionString, wantID := range map[string]string{ + "modbus-rtu:///dev/ttyUSB0": "modbus-rtu:///dev/ttyUSB0", + "modbus-rtu:///dev/ttyUSB1": "modbus-rtu:///dev/ttyUSB1", + "modbus-rtu:serial:///dev/ttyUSB2": "modbus-rtu:///dev/ttyUSB2", + } { + t.Run(connectionString, func(t *testing.T) { + _, err := live.Connect(t.Context(), connectionString) + require.Error(t, err, "no driver is registered, so this never reaches a port") + assert.Contains(t, err.Error(), wantID, "a serial connection is identified by its device") + }) + } +} + +// recordingDriverManager captures the connection string Connect hands to plc4x and never +// dials, so the string can be asserted on without a socket. Driver registration still goes to +// a real manager, so the drivers under test are the real ones. +type recordingDriverManager struct { + plc4go.PlcDriverManager + requested []string +} + +func newRecordingDriverManager() *recordingDriverManager { + return &recordingDriverManager{PlcDriverManager: plc4go.NewPlcDriverManager()} +} + +func (r *recordingDriverManager) transportAware() spi.TransportAware { + return r.PlcDriverManager.(spi.TransportAware) +} + +func (r *recordingDriverManager) RegisterTransport(transport spiTransports.Transport) { + r.transportAware().RegisterTransport(transport) +} + +func (r *recordingDriverManager) ListTransportNames() []string { + return r.transportAware().ListTransportNames() +} + +func (r *recordingDriverManager) GetTransport(name, connectionString string, options map[string][]string) (spiTransports.Transport, error) { + return r.transportAware().GetTransport(name, connectionString, options) +} + +func (r *recordingDriverManager) GetConnection(_ context.Context, connectionString string) (plc4go.PlcConnection, error) { + r.requested = append(r.requested, connectionString) + return nil, assert.AnError +} + +// TestLiveConnectRewritesAnAliasedSchemeForPlc4x is the other half of the bacnetip defect, and +// the half that bites at the prompt: plc4x looks its driver up by the scheme in the connection +// string, so a session that resolves the alias only for its own bookkeeping still fails with +// "couldn't find driver bacnetip" on a driver it has just registered. +func TestLiveConnectRewritesAnAliasedSchemeForPlc4x(t *testing.T) { + manager := newRecordingDriverManager() + live := plcsession.NewLive(plcsession.LiveOptions{DriverManager: manager, Timeout: time.Second}) + t.Cleanup(func() { + assert.NoError(t, live.Close()) + assert.NoError(t, manager.Close()) + }) + _, err := live.RegisterDriver("bacnetip") + require.NoError(t, err) + + _, err = live.Connect(t.Context(), "bacnetip://10.0.0.5:47808?foo=bar") + require.Error(t, err, "the recording manager refuses every connection") + assert.NotContains(t, err.Error(), "couldn't find driver", + "the alias must be rewritten before plc4x looks the driver up") + assert.Equal(t, []string{"bacnet-ip://10.0.0.5:47808?foo=bar"}, manager.requested, + "plc4x must be given the canonical code, with the rest of the string untouched") + assert.Contains(t, err.Error(), "bacnet-ip://10.0.0.5:47808", + "and the connection is identified by the canonical code, so one device is one connection") +} + +func TestLiveOperationsReportAnUnknownConnection(t *testing.T) { + live, _ := liveSession(t, time.Second) + tags := []plcsession.TagSpec{{Name: "a", Address: "info/*/*"}} + const unknown = "c-bus://10.0.0.5:10001" + + _, readErr := live.Read(t.Context(), unknown, tags) + assert.ErrorContains(t, readErr, "not connected") + _, writeErr := live.Write(t.Context(), unknown, tags) + assert.ErrorContains(t, writeErr, "not connected") + _, browseErr := live.Browse(t.Context(), unknown, "") + assert.ErrorContains(t, browseErr, "not connected") + _, subscribeErr := live.Subscribe(t.Context(), unknown, tags) + assert.ErrorContains(t, subscribeErr, "not connected") + assert.ErrorContains(t, live.Disconnect(unknown), "not connected") +} + +func TestLiveDiscoverReportsAnUnregisteredProtocol(t *testing.T) { + live, _ := liveSession(t, time.Second) + err := live.Discover(t.Context(), "bacnetip", func(plcsession.DiscoveryItem) { + t.Fatal("nothing can be discovered through an unregistered driver") + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "bacnet-ip", "the message should name the canonical protocol") + assert.Contains(t, err.Error(), "not registered") +} + +// TestLiveDiscoverReportsADriverWithoutDiscovery uses c-bus deliberately: it is registerable +// offline and does not support discovery, so the check is reached without any device. +func TestLiveDiscoverReportsADriverWithoutDiscovery(t *testing.T) { + live, manager := liveSession(t, time.Second) + info, err := live.RegisterDriver("c-bus") + require.NoError(t, err) + + driver, err := manager.GetDriver(info.Code) + require.NoError(t, err) + require.False(t, driver.SupportsDiscovery(), "this test is only meaningful for a driver without discovery") + + err = live.Discover(t.Context(), "c-bus", func(plcsession.DiscoveryItem) { + t.Fatal("a driver without discovery must not be asked to discover") + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "doesn't support discovery") +} + +func TestLiveCloseIsIdempotent(t *testing.T) { + manager := plc4go.NewPlcDriverManager() + t.Cleanup(func() { assert.NoError(t, manager.Close()) }) + live := plcsession.NewLive(plcsession.LiveOptions{DriverManager: manager, Timeout: time.Second}) + _, err := live.RegisterDriver("c-bus") + require.NoError(t, err) + + require.NoError(t, live.Close()) + require.NoError(t, live.Close(), "Close must be safe to call twice") +} + +// TestLiveClosedSessionRefusesEverything keeps a session that has been shut down from handing +// the UI a half-working connection. +func TestLiveClosedSessionRefusesEverything(t *testing.T) { + manager := plc4go.NewPlcDriverManager() + t.Cleanup(func() { assert.NoError(t, manager.Close()) }) + live := plcsession.NewLive(plcsession.LiveOptions{DriverManager: manager, Timeout: time.Second}) + _, err := live.RegisterDriver("c-bus") + require.NoError(t, err) + require.NoError(t, live.Close()) + + assert.Empty(t, live.Connections()) + assert.Empty(t, live.Drivers()) + + _, connectErr := live.Connect(t.Context(), "c-bus://10.0.0.5:10001") + assert.ErrorContains(t, connectErr, "session is closed") + _, registerErr := live.RegisterDriver("s7") + assert.ErrorContains(t, registerErr, "session is closed") + _, readErr := live.Read(t.Context(), "c-bus://10.0.0.5:10001", []plcsession.TagSpec{{Address: "x"}}) + assert.ErrorContains(t, readErr, "session is closed") + discoverErr := live.Discover(t.Context(), "ads", func(plcsession.DiscoveryItem) {}) + assert.ErrorContains(t, discoverErr, "session is closed") +} + +// TestLiveClosesTheDriverManagerItCreatedButNotAnInjectedOne pins the ownership rule: closing +// an injected manager would shut down drivers the caller is still using. +func TestLiveClosesTheDriverManagerItCreatedButNotAnInjectedOne(t *testing.T) { + injected := plc4go.NewPlcDriverManager() + t.Cleanup(func() { assert.NoError(t, injected.Close()) }) + live := plcsession.NewLive(plcsession.LiveOptions{DriverManager: injected, Timeout: time.Second}) + _, err := live.RegisterDriver("c-bus") + require.NoError(t, err) + require.NoError(t, live.Close()) + + assert.Equal(t, []string{"c-bus"}, injected.ListDriverNames(), + "an injected manager keeps its drivers when the session closes") + + owned := plcsession.NewLive(plcsession.LiveOptions{Timeout: time.Second}) + _, err = owned.RegisterDriver("c-bus") + require.NoError(t, err) + assert.NoError(t, owned.Close(), "an owned manager is closed with the session") +} + +// TestLiveReadNamesTheConnectionBeforeTheTags pins the order of the two complaints a malformed +// request can draw: the connection is the more useful of them, and it is the one Demo reports +// first as well, so the UI shows the same message whichever session is behind it. +func TestLiveReadNamesTheConnectionBeforeTheTags(t *testing.T) { + live, _ := liveSession(t, time.Second) + _, err := live.Read(t.Context(), "c-bus://10.0.0.5:10001", nil) + assert.ErrorContains(t, err, "not connected") +} + +// TestLiveDefaultTimeoutIsBounded guards the one option that must never be zero: an unbounded +// operation is the sixty-second hang this seam exists to prevent. +func TestLiveDefaultTimeoutIsBounded(t *testing.T) { + assert.Positive(t, plcsession.DefaultOperationTimeout) + assert.LessOrEqual(t, plcsession.DefaultOperationTimeout, 30*time.Second, + "a timeout a user will sit through is not a timeout") +} diff --git a/plc4go/tools/internal/plcsession/session.go b/plc4go/tools/internal/plcsession/session.go new file mode 100644 index 000000000..70e901786 --- /dev/null +++ b/plc4go/tools/internal/plcsession/session.go @@ -0,0 +1,202 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +// Package plcsession is the seam between the browser's user interface and the PLC. +// +// It exists for three reasons that turn out to be the same reason. +// +// The interface can be faked, so the user interface can be tested without a PLC, a network or +// a terminal. The previous UI could not be tested at all: its command actions wrote their +// results straight into global tview widgets, so there was nothing to assert on. +// +// The same fake is what --demo runs on, so demo mode is not a parallel implementation that can +// drift from the real thing; it is the seam the tests already exercise. +// +// And because nothing here mentions a plc4x type, a view can render a result without knowing +// how it was produced. That is what makes it practical to finish the read, write, browse and +// subscribe commands, which the previous UI advertised but answered with "mode switch not yet +// implemented". +package plcsession + +import ( + "context" + "time" +) + +// TagSpec identifies one tag in a request. Name is the caller's label for the tag, which comes +// back on the corresponding result; Address is the protocol-specific tag address. +type TagSpec struct { + Name string + Address string + // Value is the value to write, used only by Write. + Value string +} + +// DriverInfo describes a protocol driver as the UI lists it. +type DriverInfo struct { + // Code is the protocol code used in a connection string, such as "c-bus". + Code string + // Name is the human-readable driver name. + Name string + // SupportsDiscovery says whether Discover can be called for this protocol. + SupportsDiscovery bool +} + +// ConnectionInfo describes an open connection. +type ConnectionInfo struct { + // ID is the canonical "scheme://device" identity the UI and the commands address it by, + // where the device is the host for a networked connection and the port name for a serial + // one -- a serial connection string carries no host, so without the path every serial port + // would collapse onto the same identity. + ID string + // Protocol is the driver code serving this connection. + Protocol string + // Transport is the transport the connection runs over, when known. + Transport string +} + +// TagResult is one tag's outcome in a read, write or subscription event. +type TagResult struct { + Name string + Address string + DataType string + Value string + // Code is the response code, "OK" when the tag succeeded. + Code string +} + +// Succeeded reports whether this tag's response code indicates success. +func (t TagResult) Succeeded() bool { return t.Code == ResponseCodeOK } + +// ResponseCodeOK is the response code denoting success. +const ResponseCodeOK = "OK" + +// ReadResult is the outcome of a read request. +type ReadResult struct { + Tags []TagResult + Duration time.Duration +} + +// WriteResult is the outcome of a write request. +type WriteResult struct { + Tags []TagResult + Duration time.Duration +} + +// BrowseItem is one entry discovered by browsing a connection. +type BrowseItem struct { + Address string + Name string + DataType string + + Readable bool + Writable bool + Subscribable bool +} + +// BrowseResult is the outcome of a browse request. +type BrowseResult struct { + Items []BrowseItem + Duration time.Duration +} + +// DiscoveryItem is a device found by discovery. +type DiscoveryItem struct { + // ConnectionString is the string that would connect to the discovered device. + ConnectionString string + Protocol string + Transport string + Name string +} + +// EventKind distinguishes the sources of a message shown in the message list. +type EventKind string + +// The event kinds. These are the operations the UI can attribute a message to. +const ( + EventRead EventKind = "read" + EventWrite EventKind = "write" + EventBrowse EventKind = "browse" + EventSubscribe EventKind = "subscribe" + EventDiscover EventKind = "discover" +) + +// Event is one message to show in the message list. Subscriptions deliver these continuously; +// the one-shot operations produce one each. +type Event struct { + Kind EventKind + // Connection is the connection the event came from. + Connection string + // Received is when the event was observed. + Received time.Time + // Started is when the request that produced the event was issued. Together with Received it + // is the window a frame log is queried with: a transport carries no request identifier, so + // the only association available between a request and its bytes is temporal. + Started time.Time + // Tags carries the payload for read, write and subscription events. + Tags []TagResult + // Summary is a short human-readable description, used when Tags is not the whole story. + Summary string +} + +// Session is everything the browser's user interface needs from a PLC. +// +// Implementations are not required to be safe for concurrent use by several goroutines. The +// user interface calls a Session only from inside a command, and Bubble Tea runs one command +// at a time per message, so serialisation is the caller's job when it fans work out. +type Session interface { + // Protocols lists the protocol codes that can be registered. + Protocols() []string + + // Drivers lists the drivers registered so far. + Drivers() []DriverInfo + + // RegisterDriver registers the driver for a protocol code, along with the transports it + // needs. Registering an already-registered driver is an error, so that a typo in a + // configuration file is reported rather than silently ignored. + RegisterDriver(protocol string) (DriverInfo, error) + + // Connections lists the open connections. + Connections() []ConnectionInfo + + // Connect opens a connection from a plc4x connection string. + Connect(ctx context.Context, connectionString string) (ConnectionInfo, error) + + // Disconnect closes the connection with the given ID. + Disconnect(id string) error + + // Discover searches for devices speaking a protocol, calling sink for each one found. + Discover(ctx context.Context, protocol string, sink func(DiscoveryItem)) error + + // Read reads tags from a connection. + Read(ctx context.Context, connectionID string, tags []TagSpec) (ReadResult, error) + + // Write writes tags to a connection. Each TagSpec carries its Value. + Write(ctx context.Context, connectionID string, tags []TagSpec) (WriteResult, error) + + // Browse enumerates the tags a connection exposes. An empty query means everything. + Browse(ctx context.Context, connectionID, query string) (BrowseResult, error) + + // Subscribe subscribes to tags. Events arrive on the returned channel until the context is + // cancelled or the connection is closed, at which point the channel is closed. + Subscribe(ctx context.Context, connectionID string, tags []TagSpec) (<-chan Event, error) + + // Close releases every connection. It is safe to call more than once. + Close() error +} diff --git a/plc4go/tools/internal/progress/cli.go b/plc4go/tools/internal/progress/cli.go new file mode 100644 index 000000000..cc39c910a --- /dev/null +++ b/plc4go/tools/internal/progress/cli.go @@ -0,0 +1,357 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package progress + +import ( + "fmt" + "io" + "strconv" + "strings" + "sync" + + "charm.land/lipgloss/v2" + "golang.org/x/term" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/tui" +) + +// DefaultWidth is the width assumed when the real terminal width cannot be determined. The +// bar being replaced was fixed at 15 cells on every terminal, which is the reason width is a +// parameter here at all. +const DefaultWidth = 80 + +// minBarWidth is the narrowest bar still worth drawing. Below this the bar conveys nothing and +// the space is better spent on the percentage. +const minBarWidth = 4 + +// CLIOption adjusts a CLI reporter at construction. +type CLIOption func(*cliReporter) + +// WithHidden suppresses all drawing. It takes the flag rather than being a bare marker so that +// a caller can pass a configuration value straight through, which is what the pcap analyser's +// --hide-progress-bar does. +func WithHidden(hidden bool) CLIOption { + return func(r *cliReporter) { r.hidden = hidden } +} + +// NewCLI returns a Reporter that draws a bar of the given width into out. +// +// It writes to out and to nothing else. That is the whole point: the bar it replaces was bound +// to a package-level ANSI stderr, which is why the terminal UI could never render it. Drawing +// is a rewrite of the current line — a carriage return, not a clear-screen — so it composes +// with a terminal that is otherwise being written to line by line. +// +// It does NOT decide whether drawing is appropriate; ForWriter does. A caller that hands this +// a pipe or a file gets a bar in the pipe, which is what a test wants and a shell does not. +func NewCLI(out io.Writer, width int, theme tui.Theme, options ...CLIOption) Reporter { + // A zero tui.Theme carries no glyphs, so a caller that forgot to resolve one would draw an + // invisible bar. Resolve the plainest real theme instead of hardcoding glyphs here, which + // would put a drawing character at a call site. + if theme.Glyphs.ProgressFull == "" { + theme = tui.NewTheme(tui.Options{NoColor: true, ASCII: true}) + } + reporter := &cliReporter{out: out, width: width, theme: theme} + for _, option := range options { + option(reporter) + } + // A nil writer is treated as "hidden" rather than as a programming error, because the + // analysers pass whatever writer they were given and one of them may legitimately be unset. + if reporter.out == nil { + reporter.hidden = true + } + return reporter +} + +// ForWriter returns the reporter a plain command line should use for out. +// +// It is the single place that answers "may a bar be drawn here?", so that the answer cannot +// drift between the two analysers the way the old hardwired bars did. A bar is drawn only into +// a real terminal: into a pipe, a file or a test buffer it would interleave with the zerolog +// stream that shares the same descriptor, which is the corruption this package exists to end. +func ForWriter(out io.Writer, hide bool, theme tui.Theme) Reporter { + if hide || !IsTerminal(out) { + return Nop() + } + return NewCLI(out, TerminalWidth(out), theme) +} + +// IsTerminal reports whether w is a terminal. +// +// It matches on the Fd method rather than on *os.File so that a wrapped descriptor still +// answers truthfully. Anything that cannot produce a descriptor — a bytes.Buffer, io.Discard, +// a pipe wrapper — is not a terminal. +func IsTerminal(w io.Writer) bool { + descriptor, ok := w.(interface{ Fd() uintptr }) + if !ok { + return false + } + return term.IsTerminal(int(descriptor.Fd())) +} + +// TerminalWidth returns the width of w in cells, or DefaultWidth when w has none. +func TerminalWidth(w io.Writer) int { + descriptor, ok := w.(interface{ Fd() uintptr }) + if !ok { + return DefaultWidth + } + width, _, err := term.GetSize(int(descriptor.Fd())) + if err != nil || width <= 0 { + return DefaultWidth + } + return width +} + +// cliReporter draws a single-line bar. +// +// The mutex is not decoration: the analysers may report from the packet-mapping goroutine as +// well as the main loop, and two goroutines interleaving their escape sequences produce +// garbage rather than a race the reader would notice. +type cliReporter struct { + out io.Writer + width int + theme tui.Theme + hidden bool + + mu sync.Mutex + total int + current int + description string + started bool + done bool + // lastKey identifies the frame on screen by its geometry: the filled cell count, the whole + // percentage and the label. An analysis of a large capture calls Advance once per packet, + // tens of thousands of times, for a bar that has at most a hundred or so distinct + // appearances, so anything that does not change one of those is not worth an escape + // sequence. The consequence is that the counts on the line are as of the last redraw rather + // than of this instant, which is what every progress bar with a refresh interval does. + lastKey string +} + +func (r *cliReporter) Start(total int, description string) { + r.mu.Lock() + defer r.mu.Unlock() + r.total = total + r.current = 0 + r.description = description + r.started = true + r.done = false + r.lastKey = "" + r.draw(false) +} + +func (r *cliReporter) Advance(delta int) { + r.mu.Lock() + defer r.mu.Unlock() + r.current += delta + r.draw(false) +} + +func (r *cliReporter) SetDescription(description string) { + r.mu.Lock() + defer r.mu.Unlock() + r.description = description + r.draw(false) +} + +func (r *cliReporter) Done() { + r.mu.Lock() + defer r.mu.Unlock() + if r.done { + return + } + r.done = true + // Nothing was ever drawn, so do not leave a stray line behind. A run that fails before it + // knows how much work there is still calls Done from a defer. + if !r.started { + return + } + r.draw(true) +} + +// draw writes one frame. final terminates the line, releasing it to whatever writes next. +// +// The caller must hold r.mu. +func (r *cliReporter) draw(final bool) { + if r.hidden { + return + } + // Compute the frame's identity before building it. Advance is called once per packet, so + // on a large capture this path runs millions of times and almost always concludes that the + // frame on screen is still correct. Rendering first and discarding the result afterwards + // measured 21us per call, which is around twenty seconds of wasted work on a + // million-packet capture. + if !final && r.frameKey() == r.lastKey { + return + } + line, key := r.render() + r.lastKey = key + suffix := "" + if final { + suffix = "\n" + } + _, _ = fmt.Fprint(r.out, "\r"+line+suffix) +} + +// frameKey computes only the identity of the frame that would be drawn now, skipping the +// string building. It must stay in step with render, which is why both derive the key from +// geometry alone. +// +// The caller must hold r.mu. +func (r *cliReporter) frameKey() string { + _, key := r.renderWith(true) + return key +} + +// render builds one frame, exactly r.width cells wide where the width allows it, together with +// the key that identifies it for redraw suppression. +// +// The caller must hold r.mu. +func (r *cliReporter) render() (string, string) { + return r.renderWith(false) +} + +// renderWith computes the frame geometry and, unless keyOnly is set, the drawn line. +// +// The caller must hold r.mu. +func (r *cliReporter) renderWith(keyOnly bool) (string, string) { + width := r.width + if width <= 0 { + width = DefaultWidth + } + glyphs := r.theme.Glyphs + + total := r.total + current := max(r.current, 0) + // Overshoot is tolerated rather than reported: the analysis loops count packets they have + // walked, and a mapping stage can emit more packets than the index promised. A bar that + // overflowed its own brackets would be the worse outcome by far. + if total > 0 && current > total { + current = total + } + ratio := 0.0 + if total > 0 { + ratio = float64(current) / float64(total) + } + percent := fmt.Sprintf("%3d%%", int(ratio*100)) + // The current count is padded to the width of the total so that the counts segment keeps a + // constant width. Otherwise every extra digit steals a cell from the bar and the bar's own + // length twitches as the run proceeds. + totalText := strconv.Itoa(total) + counts := fmt.Sprintf("%*d/%s", len(totalText), current, totalText) + // A description long enough to squeeze out the bar defeats the purpose, so it never gets + // more than half the line. + label := truncate(r.description, width/2, glyphs.Ellipsis) + + // Degradation order as the line narrows. The percentage always survives; the counts go + // first because the percentage already says the same thing less precisely, and the label + // second because knowing which phase is running is worth more than the exact count. + type form struct{ withLabel, withCounts bool } + chosen := form{} + barWidth := 0 + for _, candidate := range []form{{true, true}, {true, false}, {false, true}, {false, false}} { + // Two bracket cells plus the space before the percentage. + used := 2 + lipgloss.Width(percent) + 1 + if candidate.withLabel && label != "" { + used += lipgloss.Width(label) + 1 + } + if candidate.withCounts { + used += lipgloss.Width(counts) + 1 + } + if remaining := width - used; remaining >= minBarWidth { + chosen, barWidth = candidate, remaining + break + } + } + if barWidth == 0 { + // Nothing fits comfortably. Draw the narrowest honest bar; the clip below handles a + // terminal that cannot hold even that. + barWidth = max(width-2-lipgloss.Width(percent)-1, 1) + } + + // Floor, not round: the bar must not read as complete until it is. + filled := min(int(ratio*float64(barWidth)), barWidth) + key := percent + "|" + strconv.Itoa(filled) + "|" + strconv.Itoa(barWidth) + "|" + label + if keyOnly { + // Everything below is string building, which the caller is about to discard. + return "", key + } + + var plain, styled strings.Builder + add := func(text string, style lipgloss.Style) { + plain.WriteString(text) + styled.WriteString(style.Render(text)) + } + space := func() { + plain.WriteString(" ") + styled.WriteString(" ") + } + if chosen.withLabel && label != "" { + add(label, r.theme.Muted) + space() + } + add("[", r.theme.Chrome) + add(strings.Repeat(glyphs.ProgressFull, filled), r.theme.Accent) + add(strings.Repeat(glyphs.ProgressEmpty, barWidth-filled), r.theme.Chrome) + add("]", r.theme.Chrome) + space() + add(percent, r.theme.Value) + if chosen.withCounts { + space() + add(counts, r.theme.Muted) + } + + plainWidth := lipgloss.Width(plain.String()) + if plainWidth > width { + // Narrower than "[####] 100%" can be drawn in. Keep the number and drop everything + // else rather than wrapping, which would scroll the caller's terminal. + return r.theme.Value.Render(truncate(percent, width, glyphs.Ellipsis)), key + } + // Pad to the full width. The redraw is a carriage return rather than an erase-line escape, + // so without this a shorter frame would leave the tail of the previous, longer one behind. + if pad := width - plainWidth; pad > 0 { + styled.WriteString(strings.Repeat(" ", pad)) + } + return styled.String(), key +} + +// truncate shortens text to at most width cells, marking the cut with the theme's ellipsis. +// +// It measures in cells rather than bytes or runes because the Unicode glyph set contains +// multi-byte characters, and a byte count would clip mid-character. +func truncate(text string, width int, ellipsis string) string { + if width <= 0 { + return "" + } + if lipgloss.Width(text) <= width { + return text + } + if ellipsis == "" { + ellipsis = "." + } + ellipsisWidth := lipgloss.Width(ellipsis) + if width <= ellipsisWidth { + return ellipsis + } + runes := []rune(text) + for len(runes) > 0 && lipgloss.Width(string(runes))+ellipsisWidth > width { + runes = runes[:len(runes)-1] + } + return string(runes) + ellipsis +} diff --git a/plc4go/tools/internal/progress/progress.go b/plc4go/tools/internal/progress/progress.go new file mode 100644 index 000000000..d8d2cad40 --- /dev/null +++ b/plc4go/tools/internal/progress/progress.go @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +// Package progress reports the advance of a long-running analysis without deciding how, or +// even whether, it is drawn. +// +// It exists because of a measured defect in the pcap analyser. Both of its analysis loops +// built a progress bar bound to a hardwired ANSI stderr and ignored the io.Writer they had +// been handed, so a caller could only switch the bar off, never redirect it — a terminal UI +// therefore had no way to show progress at all. Worse, that bar and zerolog wrote to the same +// stderr with no coordination, so log lines and bar frames overwrote each other and corrupted +// both. Separating "something advanced" from "how it is shown" is what fixes that: the plain +// command line draws a bar only where a bar can safely be drawn, and the terminal UI receives +// values it can render inside its own layout. +package progress + +// Reporter receives progress from long-running analysis. +// +// Implementations must tolerate being driven loosely, because the callers are analysis loops +// that break out early on a packet limit, a nil packet or a cancelled context: Advance may +// overshoot Total, Done may be called more than once, and the whole sequence may be abandoned +// after Start. None of that is an error, and none of it may panic. +type Reporter interface { + // Start announces a new unit of work of a known size. Calling it again restarts the + // reporter, which is how a multi-phase run reports each phase. + Start(total int, description string) + // Advance adds delta to the amount of work completed. + Advance(delta int) + // SetDescription renames the work in flight without disturbing the counters. + SetDescription(description string) + // Done announces that the work has finished, successfully or not. It is idempotent. + Done() +} + +// Update is one progress frame, as sent by the reporter returned from NewChannel. +// +// It carries no styling and no widths: it is the model's input, not a rendering. Any type +// satisfies bubbletea's Msg, so a program can receive this value directly. +type Update struct { + Current, Total int + Description string + Done bool +} + +// Nop returns a Reporter that discards everything. +// +// This is the default throughout, and the one tests use. Progress reporting is a courtesy to +// a human watching a terminal; nothing in the analysis depends on it, so "no reporter" has to +// be an ordinary, safe choice rather than a nil check at every call site. +func Nop() Reporter { return nopReporter{} } + +// nopReporter is stateless, so it is safe to share and safe to call in any order or from any +// goroutine. +type nopReporter struct{} + +func (nopReporter) Start(int, string) {} +func (nopReporter) Advance(int) {} +func (nopReporter) SetDescription(string) {} +func (nopReporter) Done() {} diff --git a/plc4go/tools/internal/progress/progress_test.go b/plc4go/tools/internal/progress/progress_test.go new file mode 100644 index 000000000..36d2a76e3 --- /dev/null +++ b/plc4go/tools/internal/progress/progress_test.go @@ -0,0 +1,446 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package progress_test + +import ( + "bytes" + "io" + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/progress" + "github.com/apache/plc4x-extras/plc4go/tools/internal/tui" +) + +// testTheme is the ASCII, colourless theme. Golden-ish assertions on a bar are only stable +// without escape sequences, and the ASCII glyphs are single-byte so a substring assertion says +// what it looks like it says. +func testTheme() tui.Theme { + return tui.NewTheme(tui.Options{NoColor: true, ASCII: true}) +} + +// frames splits a reporter's output into the individual redraws. Each frame is introduced by +// the carriage return that rewinds the line. +func frames(out string) []string { + var result []string + for frame := range strings.SplitSeq(out, "\r") { + frame = strings.TrimSuffix(frame, "\n") + if frame == "" { + continue + } + result = append(result, frame) + } + return result +} + +// TestCLIReporterDrawsIntoTheWriterItWasGiven is the regression test for the defect this +// package exists to fix: the bar it replaces was bound to a package-level ANSI stderr and +// ignored the writer its caller passed, so no terminal UI could ever render it. +func TestCLIReporterDrawsIntoTheWriterItWasGiven(t *testing.T) { + var out bytes.Buffer + reporter := progress.NewCLI(&out, 40, testTheme()) + + reporter.Start(10, "Analyzing") + reporter.Advance(5) + + got := out.String() + require.NotEmpty(t, got, "the reporter must write to the writer it was handed") + assert.Contains(t, got, "Analyzing", "the description belongs on the line") + assert.Contains(t, got, " 50%") + assert.Contains(t, got, "5/10", "the counts fit at 40 cells") + assert.Contains(t, got, "#", "the filled part of the bar uses the theme's glyph") + assert.Contains(t, got, "-", "the empty part of the bar uses the theme's glyph") +} + +// TestCLIReporterReachesFullOnDone pins the end state, which is the frame a user is left +// looking at. +func TestCLIReporterReachesFullOnDone(t *testing.T) { + var out bytes.Buffer + reporter := progress.NewCLI(&out, 40, testTheme()) + + reporter.Start(4, "Analyzing") + for range 4 { + reporter.Advance(1) + } + reporter.Done() + + drawn := frames(out.String()) + require.NotEmpty(t, drawn) + last := drawn[len(drawn)-1] + assert.Contains(t, last, "100%") + assert.Contains(t, last, "4/4") + assert.NotContains(t, last, "-", "at 100% no cell of the bar may still be empty") + assert.True(t, strings.HasSuffix(out.String(), "\n"), + "Done must terminate the line so that whatever writes next starts cleanly") +} + +// TestCLIReporterNeverExceedsTheWidthItWasGiven covers the second half of the old defect: the +// bar was 15 cells wide whatever the terminal was. Widths below the bar's own minimum are +// included because the responsive rules put the tools at 60 columns and less. +func TestCLIReporterNeverExceedsTheWidthItWasGiven(t *testing.T) { + for _, test := range []struct { + name string + width int + exact bool + }{ + {name: "roomy", width: 120, exact: true}, + {name: "typical", width: 80, exact: true}, + {name: "narrow", width: 40, exact: true}, + {name: "cramped", width: 20, exact: true}, + {name: "bar barely fits", width: 12, exact: true}, + {name: "percentage only", width: 6, exact: false}, + {name: "absurd", width: 3, exact: false}, + } { + t.Run(test.name, func(t *testing.T) { + var out bytes.Buffer + reporter := progress.NewCLI(&out, test.width, testTheme()) + + reporter.Start(7, "Analyzing packages") + for range 7 { + reporter.Advance(1) + } + reporter.Done() + + drawn := frames(out.String()) + require.NotEmpty(t, drawn) + for _, frame := range drawn { + if test.exact { + assert.Equal(t, test.width, lipgloss.Width(frame), + "every frame must fill the line exactly, or a shorter frame leaves the tail of a longer one behind: %q", frame) + continue + } + assert.LessOrEqual(t, lipgloss.Width(frame), test.width, + "a frame wider than the terminal would wrap and scroll the caller's output: %q", frame) + } + }) + } +} + +// TestCLIReporterDropsTheCountsBeforeThePercentageAsItNarrows pins the degradation order, +// which is the only thing that keeps a 60-column run readable. +func TestCLIReporterDropsTheCountsBeforeThePercentageAsItNarrows(t *testing.T) { + render := func(width int) string { + var out bytes.Buffer + reporter := progress.NewCLI(&out, width, testTheme()) + reporter.Start(1000, "Analyzing packages") + reporter.Advance(500) + drawn := frames(out.String()) + require.NotEmpty(t, drawn) + return drawn[len(drawn)-1] + } + + wide := render(80) + assert.Contains(t, wide, "Analyzing packages") + assert.Contains(t, wide, "500/1000") + assert.Contains(t, wide, " 50%") + + medium := render(32) + assert.Contains(t, medium, "Analyzing", "the label survives longer than the counts") + assert.NotContains(t, medium, "500/1000", "the counts are the first thing dropped") + assert.Contains(t, medium, " 50%") + + tight := render(12) + assert.NotContains(t, tight, "Analyzing") + assert.Contains(t, tight, " 50%", "the percentage is the last thing standing") +} + +// TestCLIReporterEmitsNothingWhenHidden covers --hide-progress-bar, and with it every context +// where a bar must not appear: a piped stderr, a log file, a test run. +func TestCLIReporterEmitsNothingWhenHidden(t *testing.T) { + var out bytes.Buffer + reporter := progress.NewCLI(&out, 80, testTheme(), progress.WithHidden(true)) + + reporter.Start(10, "Analyzing") + reporter.Advance(5) + reporter.SetDescription("Still analyzing") + reporter.Advance(5) + reporter.Done() + + assert.Empty(t, out.String(), "a hidden reporter must write nothing at all, not even a newline") +} + +// TestCLIReporterWithoutAWriterIsInert guards the analysers, which pass on whatever writer +// their caller gave them and cannot promise it is set. +func TestCLIReporterWithoutAWriterIsInert(t *testing.T) { + reporter := progress.NewCLI(nil, 80, testTheme()) + assert.NotPanics(t, func() { + reporter.Start(3, "Analyzing") + reporter.Advance(1) + reporter.SetDescription("elsewhere") + reporter.Done() + }) +} + +// TestCLIReporterRedrawsOnlyWhenTheFrameChanges pins the throttle. An analysis of a large +// capture calls Advance once per packet; a bar 80 cells wide has nowhere near that many +// distinct appearances, and writing an escape sequence for each one is pure cost. +func TestCLIReporterRedrawsOnlyWhenTheFrameChanges(t *testing.T) { + const total = 5000 + const width = 40 + + var out bytes.Buffer + reporter := progress.NewCLI(&out, width, testTheme()) + reporter.Start(total, "Analyzing") + for range total { + reporter.Advance(1) + } + reporter.Done() + + // A frame is only worth writing when the whole percentage or a bar cell changed, so the + // upper bound is 101 percentages plus one per bar cell - the bar can never be wider than + // the line - plus the final frame. Against 5000 Advance calls that is the point. + assert.LessOrEqual(t, len(frames(out.String())), 101+width+1, + "redrawing an identical frame buys nothing and costs an escape sequence per packet") + assert.Contains(t, out.String(), "100%", "throttling must not swallow the final frame") +} + +// TestCLIReporterDescriptionChangeIsVisibleImmediately proves the throttle keys on the whole +// frame and not merely on the counters, so a phase change is never held back. +func TestCLIReporterDescriptionChangeIsVisibleImmediately(t *testing.T) { + var out bytes.Buffer + reporter := progress.NewCLI(&out, 60, testTheme()) + + reporter.Start(100, "Indexing") + reporter.SetDescription("Filtering") + + drawn := frames(out.String()) + require.Len(t, drawn, 2, "a renamed phase is a different frame and must be redrawn") + assert.Contains(t, drawn[0], "Indexing") + assert.Contains(t, drawn[1], "Filtering") +} + +// TestNopReporterIsSafeInAnyOrder documents that "no reporter" is an ordinary choice. The +// analysers call Done from a defer and may never have reached Start. +func TestNopReporterIsSafeInAnyOrder(t *testing.T) { + reporter := progress.Nop() + assert.NotPanics(t, func() { + reporter.Done() + reporter.Advance(3) + reporter.SetDescription("out of order") + reporter.Start(5, "late") + reporter.Advance(-1) + reporter.Done() + reporter.Done() + }) +} + +// TestChannelReporterDeliversEveryStateChangeToAReader is the terminal-UI path: absolute +// counters a model can render, in the order they happened. +func TestChannelReporterDeliversEveryStateChangeToAReader(t *testing.T) { + updates := make(chan progress.Update, 8) + reporter := progress.NewChannel(updates) + + reporter.Start(4, "Analyzing") + reporter.Advance(1) + reporter.SetDescription("Comparing") + reporter.Advance(3) + reporter.Done() + close(updates) + + var got []progress.Update + for update := range updates { + got = append(got, update) + } + assert.Equal(t, []progress.Update{ + {Current: 0, Total: 4, Description: "Analyzing"}, + {Current: 1, Total: 4, Description: "Analyzing"}, + {Current: 1, Total: 4, Description: "Comparing"}, + {Current: 4, Total: 4, Description: "Comparing"}, + {Current: 4, Total: 4, Description: "Comparing", Done: true}, + }, got) +} + +// TestChannelReporterDropsRatherThanBlockingWhenNobodyReads is the property that keeps the +// display from throttling the analysis. An unbuffered channel with no receiver is the extreme +// case, so every send must be discarded and the calls must still return. +func TestChannelReporterDropsRatherThanBlockingWhenNobodyReads(t *testing.T) { + updates := make(chan progress.Update) + reporter := progress.NewChannel(updates) + + reporter.Start(100, "Analyzing") + for range 50 { + reporter.Advance(1) + } + reporter.Done() + + counter, ok := reporter.(interface{ Dropped() int }) + require.True(t, ok, "the channel reporter must expose its drop count for diagnosis") + assert.Equal(t, 52, counter.Dropped(), + "with nobody receiving, the Start, the 50 Advances and the Done must all be dropped") +} + +// TestChannelReporterKeepsCountingAfterUpdatesAreDropped proves dropping is lossless in the +// sense that matters: Update carries absolute counters, so the next delivered frame is +// complete regardless of how many were discarded before it. +func TestChannelReporterKeepsCountingAfterUpdatesAreDropped(t *testing.T) { + updates := make(chan progress.Update, 1) + reporter := progress.NewChannel(updates) + + reporter.Start(10, "Analyzing") + for range 5 { + reporter.Advance(1) + } + // Drain the one frame the buffer accepted; everything after it was dropped. + <-updates + reporter.Advance(1) + + assert.Equal(t, progress.Update{Current: 6, Total: 10, Description: "Analyzing"}, <-updates, + "the reporter's own counter must survive the frames nobody received") +} + +// TestReportersTolerateOvershootAndRepeatedDone covers the way the analysis loops actually +// drive a reporter: they break out early, they count past the index's promise, and they call +// Done from a defer as well as on the happy path. +func TestReportersTolerateOvershootAndRepeatedDone(t *testing.T) { + t.Run("cli", func(t *testing.T) { + var out bytes.Buffer + reporter := progress.NewCLI(&out, 40, testTheme()) + + reporter.Start(2, "Analyzing") + reporter.Advance(5) + reporter.Done() + reporter.Done() + + got := out.String() + assert.Contains(t, got, "100%", "an overshoot is a finished bar, not a broken one") + assert.Contains(t, got, "2/2", "the reported count is clamped to the total") + assert.Equal(t, 1, strings.Count(got, "\n"), + "a second Done must not terminate a second line") + }) + + t.Run("channel", func(t *testing.T) { + updates := make(chan progress.Update, 8) + reporter := progress.NewChannel(updates) + + reporter.Start(2, "Analyzing") + reporter.Advance(5) + reporter.Done() + reporter.Done() + close(updates) + + var done int + for update := range updates { + assert.LessOrEqual(t, update.Current, update.Total, + "a model dividing Current by Total must never see a ratio above one") + if update.Done { + done++ + } + } + assert.Equal(t, 1, done, "Done is idempotent, so exactly one terminal frame is sent") + }) + + t.Run("nop", func(t *testing.T) { + reporter := progress.Nop() + assert.NotPanics(t, func() { + reporter.Start(2, "Analyzing") + reporter.Advance(5) + reporter.Done() + reporter.Done() + }) + }) +} + +// TestForWriterDrawsNothingIntoSomethingThatIsNotATerminal is the interleaving fix stated as a +// property: a bar and the zerolog stream share stderr, so unless stderr is a terminal being +// watched by a human there must be no bar at all. +func TestForWriterDrawsNothingIntoSomethingThatIsNotATerminal(t *testing.T) { + var out bytes.Buffer + reporter := progress.ForWriter(&out, false, testTheme()) + + reporter.Start(10, "Analyzing") + reporter.Advance(10) + reporter.Done() + + assert.Empty(t, out.String(), "a buffer, a pipe or a log file must never receive bar frames") + assert.False(t, progress.IsTerminal(&out)) + assert.Equal(t, progress.DefaultWidth, progress.TerminalWidth(&out), + "a writer with no descriptor has no width to query, so the default stands in") +} + +// TestTheDoneFrameSurvivesAFullBuffer pins the one frame that must not be dropped. +// +// An intermediate frame is superseded by the next one, so losing it costs nothing. The +// terminal frame has no successor: dropping it leaves a bar stranded at whatever fraction it +// last showed, so a finished run reads as stuck. It is also bounded -- it happens once -- so +// it can afford to displace a stale update to get through. +func TestTheDoneFrameSurvivesAFullBuffer(t *testing.T) { + // Capacity one, deliberately: after Start the buffer is already full, so every subsequent + // send has to contend for it. + updates := make(chan progress.Update, 1) + reporter := progress.NewChannel(updates) + + reporter.Start(10, "analyzing") + for range 50 { + reporter.Advance(1) + } + reporter.Done() + + // Drain what is queued and look for the terminal frame. + close(updates) + var last progress.Update + var sawDone bool + for update := range updates { + last = update + if update.Done { + sawDone = true + } + } + + assert.True(t, sawDone, "the terminal frame must reach the UI even when the buffer is full") + assert.True(t, last.Done, "the terminal frame must be the last one queued") + assert.Equal(t, 10, last.Current, "the final frame must show the work complete, not stranded short") + assert.Equal(t, 10, last.Total) +} + +// TestIntermediateFramesAreStillDroppedRatherThanBlocking guards the other half: making the +// terminal frame reliable must not turn ordinary progress into backpressure on the analysis. +func TestIntermediateFramesAreStillDroppedRatherThanBlocking(t *testing.T) { + updates := make(chan progress.Update, 1) + reporter := progress.NewChannel(updates) + + reporter.Start(1000, "analyzing") + for range 500 { + reporter.Advance(1) + } + + dropper, ok := reporter.(interface{ Dropped() int }) + require.True(t, ok, "the channel reporter should expose its drop count for exactly this test") + assert.Positive(t, dropper.Dropped(), "a full buffer must drop intermediate frames, not block the analysis") +} + +// BenchmarkCLIAdvance guards the hot path. Advance runs once per packet, so on a large capture +// it runs millions of times, and almost every call concludes that the frame already on screen +// is still correct. Building the frame first and discarding it measured 21us per call here -- +// roughly twenty seconds of wasted work on a million-packet capture -- which is why the frame +// key is computed before the strings are built. Expect hundreds of nanoseconds, not tens of +// microseconds. +func BenchmarkCLIAdvance(b *testing.B) { + reporter := progress.NewCLI(io.Discard, 80, tui.NewTheme(tui.Options{Dark: true})) + reporter.Start(b.N, "analyzing") + b.ResetTimer() + for range b.N { + reporter.Advance(1) + } + reporter.Done() +} diff --git a/plc4go/tools/internal/progress/tea.go b/plc4go/tools/internal/progress/tea.go new file mode 100644 index 000000000..708d1ef07 --- /dev/null +++ b/plc4go/tools/internal/progress/tea.go @@ -0,0 +1,154 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package progress + +import "sync" + +// NewChannel returns a Reporter that sends an Update per change instead of drawing anything. +// +// This is the terminal-UI half of the package. A Bubble Tea model cannot be written to from an +// analysis goroutine — its state belongs to the event loop — so the analysis publishes values +// and the program turns them into messages, typically with a command that reads ch once and +// re-arms itself. +// +// The channel should be buffered. An unbuffered one still works but will drop nearly +// everything, since a send only succeeds while the program happens to be waiting. +// +// The channel is bidirectional rather than send-only so that the terminal frame can displace a +// stale update instead of being dropped; see send. +func NewChannel(ch chan Update) Reporter { + return &channelReporter{ch: ch} +} + +// channelReporter publishes progress to a Bubble Tea program. +type channelReporter struct { + ch chan Update + + mu sync.Mutex + total int + current int + description string + done bool + dropped int +} + +func (r *channelReporter) Start(total int, description string) { + r.mu.Lock() + defer r.mu.Unlock() + r.total = total + r.current = 0 + r.description = description + r.done = false + r.send(false) +} + +func (r *channelReporter) Advance(delta int) { + r.mu.Lock() + defer r.mu.Unlock() + r.current += delta + r.send(false) +} + +func (r *channelReporter) SetDescription(description string) { + r.mu.Lock() + defer r.mu.Unlock() + r.description = description + r.send(false) +} + +func (r *channelReporter) Done() { + r.mu.Lock() + defer r.mu.Unlock() + if r.done { + return + } + r.done = true + r.send(true) +} + +// Dropped reports how many updates were discarded because nobody was ready to receive them. +// +// It is diagnostic, not part of Reporter: reach it with a type assertion for +// interface{ Dropped() int }. It exists so that a test can prove the drop actually happens +// rather than inferring it from a test that merely did not hang. +func (r *channelReporter) Dropped() int { + r.mu.Lock() + defer r.mu.Unlock() + return r.dropped +} + +// send publishes the current state, or drops it. +// +// The send is deliberately non-blocking. The alternative — waiting for the UI to catch up — +// would let the display throttle the analysis, and a progress frame that arrives late is +// worthless anyway: the next one supersedes it entirely, because Update carries absolute +// counters rather than a delta. Dropping is therefore lossless in the only sense that matters. +// +// The terminal frame is the exception. Dropping it strands the bar at whatever fraction it +// last showed -- a run that finished but reads as stuck at 99% -- and unlike an intermediate +// frame nothing supersedes it. It is also bounded: it happens once. So when the buffer is +// full, the final update displaces the oldest queued one rather than being discarded. That +// still cannot block, because both operations are non-blocking. +// +// A UI should nonetheless treat the analysis function returning as the authoritative signal +// that the run ended; this frame just gets the bar to its final position sooner. +// +// The caller must hold r.mu. +func (r *channelReporter) send(done bool) { + current := max(r.current, 0) + // Clamp for the same reason the CLI reporter does: a model dividing Current by Total must + // not be handed a ratio above one. + if r.total > 0 && current > r.total { + current = r.total + } + update := Update{ + Current: current, + Total: r.total, + Description: r.description, + Done: done, + } + // A nil channel is never ready, so this degenerates to "drop everything" rather than + // blocking forever, which is the right answer for a reporter wired up wrongly. + select { + case r.ch <- update: + return + default: + } + + if !done { + r.dropped++ + return + } + + // Make room for the final frame by discarding the oldest queued update, then retry. If the + // receiver drained the channel in between, the retry simply succeeds on an empty buffer. + select { + case <-r.ch: + r.dropped++ + default: + } + select { + case r.ch <- update: + default: + // A nil or zero-capacity channel with no reader: nothing can be delivered, and the + // analysis must not wait on a display. + r.dropped++ + } +} diff --git a/plc4go/tools/internal/tui/glyphs.go b/plc4go/tools/internal/tui/glyphs.go new file mode 100644 index 000000000..f61743250 --- /dev/null +++ b/plc4go/tools/internal/tui/glyphs.go @@ -0,0 +1,147 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package tui + +import ( + "os" + "strings" + + "charm.land/lipgloss/v2" +) + +// Glyphs is the set of drawing characters a UI uses. +// +// It exists so that no call site ever hardcodes a box-drawing character. The tools default to +// the Unicode set because it looks considerably better, but a terminal, font or console that +// cannot render it would show mojibake and, worse, miscount cell widths and break the layout. +// Every glyph therefore has an ASCII counterpart of the SAME display width. +type Glyphs struct { + // Border is the pane border style. + Border lipgloss.Border + + // Selected marks the highlighted row of a list or table. + Selected string + // Unselected occupies the same width as Selected for rows that are not selected, so that + // text does not shift when the selection moves. + Unselected string + + // Yes and No mark a boolean capability, such as whether a driver supports discovery. + Yes string + No string + + // Inbound and Outbound mark the direction a message travelled. + Inbound string + Outbound string + + // Ok, Warn and Err prefix a status. + Ok string + Warn string + Err string + + // Ellipsis truncates overlong text. + Ellipsis string + + // Separator joins items on the status line. + Separator string + + // ProgressFull and ProgressEmpty draw a progress bar. + ProgressFull string + ProgressEmpty string + + // Spinner frames are cycled while work is in flight. + Spinner []string +} + +// UnicodeGlyphs is the default, better-looking set. +func UnicodeGlyphs() Glyphs { + return Glyphs{ + Border: lipgloss.RoundedBorder(), + Selected: "▸", + Unselected: " ", + Yes: "●", + No: "○", + Inbound: "←", + Outbound: "→", + Ok: "✓", + Warn: "!", + Err: "✖", + Ellipsis: "â€Ļ", + Separator: "¡", + ProgressFull: "█", + ProgressEmpty: "░", + Spinner: []string{"⠋", "⠙", "â š", "â ¸", "â ŧ", "â ´", "â Ļ", "â §", "⠇", "⠏"}, + } +} + +// ASCIIGlyphs is the fallback set. Every entry has the same display width as its Unicode +// counterpart, so switching sets cannot change a layout's arithmetic. +func ASCIIGlyphs() Glyphs { + return Glyphs{ + Border: lipgloss.ASCIIBorder(), + Selected: ">", + Unselected: " ", + Yes: "*", + No: "-", + Inbound: "<", + Outbound: ">", + Ok: "+", + Warn: "!", + Err: "x", + Ellipsis: ".", + Separator: "|", + ProgressFull: "#", + ProgressEmpty: "-", + Spinner: []string{"|", "/", "-", "\\"}, + } +} + +// GlyphsFor returns the Unicode set when unicode is true and the ASCII set otherwise. +func GlyphsFor(unicode bool) Glyphs { + if unicode { + return UnicodeGlyphs() + } + return ASCIIGlyphs() +} + +// SupportsUnicode guesses whether the terminal can render the Unicode glyph set, by looking +// for a UTF-8 locale in the usual environment variables. +// +// lookup is the environment accessor, so this is testable; pass os.Getenv in production. +// The guess is deliberately conservative in only one direction: a terminal that can render +// Unicode but advertises no UTF-8 locale merely gets the plainer set, whereas guessing wrong +// the other way corrupts the display. +func SupportsUnicode(lookup func(string) string) bool { + for _, key := range []string{"LC_ALL", "LC_CTYPE", "LANG"} { + value := strings.ToUpper(lookup(key)) + if value == "" { + continue + } + // The first variable that is set decides, mirroring how locales resolve. + return strings.Contains(value, "UTF-8") || strings.Contains(value, "UTF8") + } + // Windows Terminal and most modern emulators cope, but a bare Windows console historically + // did not, and no locale variable is set there. + return false +} + +// SupportsUnicodeFromEnv applies SupportsUnicode to the real environment. +func SupportsUnicodeFromEnv() bool { + return SupportsUnicode(os.Getenv) +} diff --git a/plc4go/tools/internal/tui/keys.go b/plc4go/tools/internal/tui/keys.go new file mode 100644 index 000000000..24d669faf --- /dev/null +++ b/plc4go/tools/internal/tui/keys.go @@ -0,0 +1,511 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package tui + +import ( + "strings" + + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/lipgloss/v2" +) + +// The keymap shared by both terminal tools, and the footer that advertises it. +// +// Two properties here were forced by defects found in review rather than chosen for elegance. +// +// First, both tools are REPLs: a text prompt owns the keyboard almost all of the time. A +// design review of the sketched UI counted fifteen bare-character shortcuts (q ? 1-4 [ ] w z +// y L f e o a x c d r) offered alongside that prompt, which would mean that typing the word +// "read" fired the r, e, a and d pane actions. Every binding here therefore declares a Scope, +// and KeyMap.ForFocus resolves the map for whoever currently owns the keyboard. A binding +// that is out of scope comes back with no keys at all, which makes key.Matches refuse it and +// makes the help view omit it. No call site is able to forget the rule because no call site +// gets to see an out-of-scope binding in the first place. +// +// Second, bubbles/help ellipsizes the TAIL of ShortHelp as soon as it no longer fits the +// width (see help.Model.shouldAddItem), so at eighty columns the last binding is silently +// dropped. The quit binding is consequently first in every ShortHelp below, and the help +// labels are deliberately plain ASCII: the footer is the most width-critical line in either +// UI, and it is the one place where a glyph that a font measures as two cells instead of one +// would quietly eat a binding. + +// Focus says which part of the UI owns the keyboard. +type Focus int + +const ( + // FocusPrompt is the command prompt. This is the default and the common case: these are + // REPLs, not viewers with a command line bolted on. + FocusPrompt Focus = iota + // FocusPane is a content pane: a table, a list or a viewport. + FocusPane + // FocusOverlay is a modal form, such as the browser's request composer. + FocusOverlay +) + +// String makes Focus readable in test failures and logs. +func (f Focus) String() string { + switch f { + case FocusPrompt: + return "prompt" + case FocusPane: + return "pane" + case FocusOverlay: + return "overlay" + default: + return "unknown" + } +} + +// TextEntry reports whether this focus is one where the user is typing text, and so where a +// bare character has to mean that character and nothing else. +func (f Focus) TextEntry() bool { + return f == FocusPrompt || f == FocusOverlay +} + +// Scope says in which focus states a binding is live. +type Scope int + +const ( + // ScopeGlobal is live in every focus. Only modifier combinations and named keys may be + // global; a bare character never can, because at a text-entry focus it is text. + ScopeGlobal Scope = iota + // ScopePane is live only while a content pane owns the keyboard. This is where the bare + // characters live. + ScopePane + // ScopeInput is live at any text-entry focus, the prompt and the modal forms alike. + ScopeInput + // ScopePrompt is live only at the command prompt. + ScopePrompt +) + +// String makes Scope readable in test failures. +func (s Scope) String() string { + switch s { + case ScopeGlobal: + return "global" + case ScopePane: + return "pane" + case ScopeInput: + return "input" + case ScopePrompt: + return "prompt" + default: + return "unknown" + } +} + +// Live reports whether a binding of this scope is active for focus f. +func (s Scope) Live(f Focus) bool { + switch s { + case ScopeGlobal: + return true + case ScopePane: + return f == FocusPane + case ScopeInput: + return f.TextEntry() + case ScopePrompt: + return f == FocusPrompt + default: + return false + } +} + +// Scoped returns b as given when scope is live for focus f, and an empty binding otherwise. +// +// Tool-specific bindings go through this so that they get exactly the same treatment as the +// shared ones. A tool should never compare a Focus itself: that is how a binding ends up live +// at the prompt by accident. +func Scoped(b key.Binding, scope Scope, f Focus) key.Binding { + if scope.Live(f) { + return b + } + return key.NewBinding() +} + +// spec is the source form of a shared binding. +// +// keys are live wherever scope is live. bare holds extra single-character shortcuts that are +// live ONLY at pane focus, which is what lets an action have both a prompt-safe key and a +// convenient one: quit is ctrl+c everywhere and additionally q in a pane. +type spec struct { + scope Scope + keys []string + bare []string + // label is the help label to show while the bare keys are live, alt the one to show while + // they are not. An empty alt means label is used in both cases. + label string + alt string + desc string +} + +// resolve builds the binding for focus f. +// +// A binding with no keys is what disables an action: key.Binding.Enabled reports false for an +// empty key set, key.Matches skips disabled bindings, and help.Model omits them. That is the +// entire scoping mechanism, and it needs no cooperation from any call site. +func (s spec) resolve(f Focus) key.Binding { + var keys []string + if s.scope.Live(f) { + keys = append(keys, s.keys...) + } + if f == FocusPane { + keys = append(keys, s.bare...) + } + if len(keys) == 0 { + return key.NewBinding() + } + label := s.label + if f != FocusPane && s.alt != "" { + label = s.alt + } + if label == "" || s.desc == "" { + // No help text: the binding works but stays out of the footer. Used for the second + // half of a pair whose help is carried by the first, such as down beside up. + return key.NewBinding(key.WithKeys(keys...)) + } + return key.NewBinding(key.WithKeys(keys...), key.WithHelp(label, s.desc)) +} + +// The binding table. Everything the two tools have in common lives here; anything only one +// tool has stays in that tool and is scoped with Scoped. +// +// Which keys are available is not a free choice. bubbles/v2/textinput claims right ctrl+f +// left ctrl+b alt+right ctrl+right alt+f alt+left ctrl+left alt+b alt+backspace ctrl+w +// ctrl+backspace alt+delete alt+d ctrl+delete ctrl+k ctrl+u backspace ctrl+h delete ctrl+d +// home ctrl+a end ctrl+e ctrl+v tab up down ctrl+n and ctrl+p for line editing, so a global +// binding has to come from what is left: esc, enter, shift+tab, the page keys, the function +// keys and alt+digit. +var ( + specQuit = spec{scope: ScopeGlobal, keys: []string{"ctrl+c"}, bare: []string{"q"}, label: "q", alt: "ctrl+c", desc: "quit"} + specHelp = spec{scope: ScopeGlobal, keys: []string{"f1"}, bare: []string{"?"}, label: "?", alt: "f1", desc: "help"} + specCancel = spec{scope: ScopeGlobal, keys: []string{"esc"}, label: "esc", desc: "cancel"} + // specEOF is ctrl+d, which the tview interfaces also quit on. It cannot just be added to + // specQuit: bubbles/textinput binds ctrl+d to delete-forward, so an unconditional quit + // would take a line-editing key away. The shell rule resolves it -- ctrl+d ends the session + // on an empty line and deletes a character otherwise -- and that is how every REPL a user + // arrives from behaves. It carries no help text of its own; it shares quit's entry. + specEOF = spec{scope: ScopeGlobal, keys: []string{"ctrl+d"}} + // The help says "1-4" at every focus, not "alt+1-4" away from a pane. alt+digit is bound + // but unreliable -- the common terminals bind it to switching their own tabs and never + // deliver it -- so the tools also accept a bare digit wherever it cannot be text, and that + // is the binding worth advertising. Each pane draws its number in its title. + specPaneJump = spec{scope: ScopeGlobal, keys: []string{"alt+1", "alt+2", "alt+3", "alt+4"}, bare: []string{"1", "2", "3", "4"}, label: "1-4", desc: "pane"} + specPageUp = spec{scope: ScopeGlobal, keys: []string{"pgup"}, label: "pgup/pgdown", desc: "scroll"} + specPageDown = spec{scope: ScopeGlobal, keys: []string{"pgdown"}} + specSubmit = spec{scope: ScopeInput, keys: []string{"enter"}, label: "enter", desc: "run"} + // Select is Submit's counterpart for a pane. Submit is ScopeInput, so without this + // nothing at all responded to enter while a pane held the keyboard, and a highlighted row + // could not be chosen. + specSelect = spec{scope: ScopePane, keys: []string{"enter"}, label: "enter", desc: "select"} + specFocusPanes = spec{scope: ScopePrompt, keys: []string{"shift+tab"}, label: "shift+tab", desc: "panes"} + specComplete = spec{scope: ScopePrompt, keys: []string{"tab"}, label: "tab", desc: "complete"} + specHistoryPrev = spec{scope: ScopePrompt, keys: []string{"up"}, label: "up/down", desc: "history"} + specHistoryNext = spec{scope: ScopePrompt, keys: []string{"down"}} + specSuggestNext = spec{scope: ScopePrompt, keys: []string{"ctrl+n"}, label: "ctrl+n/p", desc: "suggestion"} + specSuggestPrev = spec{scope: ScopePrompt, keys: []string{"ctrl+p"}} + specFocusPrompt = spec{scope: ScopePane, keys: []string{"i", ":"}, label: ":", desc: "prompt"} + specNextPane = spec{scope: ScopePane, keys: []string{"tab"}, label: "tab", desc: "next pane"} + specPrevPane = spec{scope: ScopePane, keys: []string{"shift+tab"}} + specUp = spec{scope: ScopePane, keys: []string{"up", "k"}, label: "up/down", desc: "move"} + specDown = spec{scope: ScopePane, keys: []string{"down", "j"}} + specTop = spec{scope: ScopePane, keys: []string{"g"}, label: "g/G", desc: "top/end"} + specBottom = spec{scope: ScopePane, keys: []string{"G"}} + specFilter = spec{scope: ScopePane, keys: []string{"/"}, label: "/", desc: "filter"} + specWrap = spec{scope: ScopePane, keys: []string{"w"}, label: "w", desc: "wrap"} + specYank = spec{scope: ScopePane, keys: []string{"y"}, label: "y", desc: "yank"} + specFollow = spec{scope: ScopePane, keys: []string{"f"}, label: "f", desc: "follow"} + specExpand = spec{scope: ScopePane, keys: []string{"e"}, label: "e", desc: "expand"} + specLogLevel = spec{scope: ScopePane, keys: []string{"L"}, label: "L", desc: "level"} +) + +// KeyMap is the resolved keymap for one focus. It satisfies help.KeyMap. +// +// Get one from NewKeyMap, which resolves for FocusPrompt because that is where a REPL starts, +// and re-resolve with ForFocus whenever focus moves. +type KeyMap struct { + // Always available. + Quit key.Binding + // EOF is ctrl+d. Act on it only when there is nothing to delete; see specEOF. + EOF key.Binding + Help key.Binding + Cancel key.Binding + PaneJump key.Binding + PageUp key.Binding + PageDown key.Binding + + // Text entry, at the prompt or in a modal form. + Submit key.Binding + // Select activates the highlighted row of a pane. + Select key.Binding + + // The prompt only. + FocusPanes key.Binding + Complete key.Binding + HistoryPrev key.Binding + HistoryNext key.Binding + SuggestNext key.Binding + SuggestPrev key.Binding + + // A content pane only. These are the bare characters that must not fire while the user is + // typing a command name. + FocusPrompt key.Binding + NextPane key.Binding + PrevPane key.Binding + Up key.Binding + Down key.Binding + Top key.Binding + Bottom key.Binding + Filter key.Binding + Wrap key.Binding + Yank key.Binding + Follow key.Binding + Expand key.Binding + LogLevel key.Binding + + focus Focus +} + +// NewKeyMap returns the keymap for the prompt, which is where both tools start. +func NewKeyMap() KeyMap { + return keyMapFor(FocusPrompt) +} + +// Focus reports which focus this keymap was resolved for. +func (k KeyMap) Focus() Focus { return k.focus } + +// ForFocus returns the keymap resolved for focus f. +// +// It ignores the receiver's own state on purpose: resolution is a pure function of the focus, +// so ForFocus can never carry a stale binding over from the focus it was called on. +func (k KeyMap) ForFocus(f Focus) KeyMap { + return keyMapFor(f) +} + +// keyMapFor resolves every spec for one focus. +func keyMapFor(f Focus) KeyMap { + return KeyMap{ + focus: f, + + Quit: specQuit.resolve(f), + EOF: specEOF.resolve(f), + Help: specHelp.resolve(f), + Cancel: specCancel.resolve(f), + PaneJump: specPaneJump.resolve(f), + PageUp: specPageUp.resolve(f), + PageDown: specPageDown.resolve(f), + + Submit: specSubmit.resolve(f), + Select: specSelect.resolve(f), + + FocusPanes: specFocusPanes.resolve(f), + Complete: specComplete.resolve(f), + HistoryPrev: specHistoryPrev.resolve(f), + HistoryNext: specHistoryNext.resolve(f), + SuggestNext: specSuggestNext.resolve(f), + SuggestPrev: specSuggestPrev.resolve(f), + + FocusPrompt: specFocusPrompt.resolve(f), + NextPane: specNextPane.resolve(f), + PrevPane: specPrevPane.resolve(f), + Up: specUp.resolve(f), + Down: specDown.resolve(f), + Top: specTop.resolve(f), + Bottom: specBottom.resolve(f), + Filter: specFilter.resolve(f), + Wrap: specWrap.resolve(f), + Yank: specYank.resolve(f), + Follow: specFollow.resolve(f), + Expand: specExpand.resolve(f), + LogLevel: specLogLevel.resolve(f), + } +} + +// ShortHelp is the one-line footer. +// +// Quit is first in every variant, and that ordering is load-bearing rather than tidy: +// help.Model renders items until they no longer fit and then ellipsizes the rest, so the last +// entry is the one that disappears at eighty columns. Whatever else the user loses when the +// terminal is narrow, they keep the way out. +func (k KeyMap) ShortHelp() []key.Binding { + switch k.focus { + case FocusPane: + return []key.Binding{k.Quit, k.FocusPrompt, k.NextPane, k.Up, k.Filter, k.Help} + case FocusOverlay: + return []key.Binding{k.Quit, k.Submit, k.Cancel, k.Help} + default: + return []key.Binding{k.Quit, k.Submit, k.Complete, k.HistoryPrev, k.FocusPanes, k.Help} + } +} + +// FullHelp is the expanded help, one column per group. Bindings that are out of scope resolve +// to empty and drop out on their own, so the expanded help doubles as an honest statement of +// what the current focus can actually do. +func (k KeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Quit, k.Help, k.Cancel}, + {k.Submit, k.Complete, k.HistoryPrev, k.SuggestNext}, + {k.FocusPrompt, k.FocusPanes, k.NextPane, k.PaneJump}, + {k.Up, k.Top, k.PageUp, k.Filter}, + {k.Wrap, k.Yank, k.Follow, k.Expand, k.LogLevel}, + } +} + +// HelpFooter renders a KeyMap as the footer line, or as the expanded help when asked. +// +// It is a component, not a tea.Model: View returns a string, because only the top-level model +// returns a tea.View. +type HelpFooter struct { + model help.Model +} + +// NewHelpFooter builds a footer styled by the theme. It takes its separator and ellipsis from +// the theme's glyphs so that the footer degrades with the rest of the UI on a terminal that +// cannot draw the Unicode set. +func NewHelpFooter(theme Theme) HelpFooter { + model := help.New() + model.ShortSeparator = " " + theme.Glyphs.Separator + " " + model.FullSeparator = " " + model.Ellipsis = theme.Glyphs.Ellipsis + model.Styles = help.Styles{ + ShortKey: theme.Key, + ShortDesc: theme.Muted, + ShortSeparator: theme.Chrome, + Ellipsis: theme.Chrome, + FullKey: theme.Key, + FullDesc: theme.Muted, + FullSeparator: theme.Chrome, + } + return HelpFooter{model: model} +} + +// SetWidth sets the width the footer has to fit in. +func (h *HelpFooter) SetWidth(width int) { h.model.SetWidth(width) } + +// Width reports the footer's width. +func (h HelpFooter) Width() int { return h.model.Width() } + +// SetShowAll switches between the one-line footer and the expanded help. +func (h *HelpFooter) SetShowAll(showAll bool) { h.model.ShowAll = showAll } + +// ShowAll reports whether the expanded help is showing. +func (h HelpFooter) ShowAll() bool { return h.model.ShowAll } + +// View renders the footer for a keymap that has already been resolved for the current focus. +// +// The fitting to the width is done here rather than left to help.Model, because help.Model +// does not actually do it. help.Model.shouldAddItem truncates only while there is still room +// for its own ellipsis and otherwise falls through and adds the item anyway, then carries on +// adding the rest (verified in bubbles/v2 v2.2.1): asking it for the six-binding prompt +// footer at forty columns returns eighty-four cells, which wraps and pushes every row of the +// layout down one. Fitting here drops whole bindings from the TAIL, which is the other half +// of why ShortHelp leads with quit. +func (h HelpFooter) View(keys KeyMap) string { + if h.model.ShowAll { + return h.fullView(keys.FullHelp()) + } + return h.shortView(keys.ShortHelp()) +} + +// ViewWith renders the footer for a keymap plus the bindings a tool adds of its own. +// +// A tool's own bindings have to reach the help the same way the shared ones do. The pcap +// analyzer's eight most tool-specific keys -- the tab pair, the three detail views, and open, +// analyze and extract -- worked but appeared in neither the footer nor the expanded help, so +// reading the documentation was the only way to find out they existed. That is the same +// dead-affordance defect as a help that advertises a key it does not handle, in the other +// direction: the expanded help is meant to be a complete statement of what a focus can do. +// +// Out-of-scope bindings resolve to empty and drop out on their own, so a caller passes its +// whole set and lets the focus decide, exactly as the shared bindings do. +func (h HelpFooter) ViewWith(keys KeyMap, short []key.Binding, full [][]key.Binding) string { + if h.model.ShowAll { + return h.fullView(append(keys.FullHelp(), full...)) + } + return h.shortView(append(keys.ShortHelp(), short...)) +} + +// unlimited returns the help model with its width limit removed, so that its own truncation +// cannot interfere with the fitting done here. +func (h HelpFooter) unlimited() help.Model { + model := h.model + model.SetWidth(0) + return model +} + +// shortView renders the one-line footer, dropping trailing bindings until it fits. +func (h HelpFooter) shortView(bindings []key.Binding) string { + width := h.model.Width() + model := h.unlimited() + + view := model.ShortHelpView(bindings) + if width <= 0 || lipgloss.Width(view) <= width { + return view + } + + // The ellipsis says that something was dropped. It has to be budgeted for, or the fitting + // reintroduces the overflow it exists to prevent. + ellipsis := " " + h.model.Styles.Ellipsis.Inline(true).Render(h.model.Ellipsis) + for count := len(bindings) - 1; count > 0; count-- { + candidate := model.ShortHelpView(bindings[:count]) + if lipgloss.Width(candidate)+lipgloss.Width(ellipsis) <= width { + return candidate + ellipsis + } + } + + // Narrower than the first binding plus an ellipsis. This is below MinWidth, where the + // tools draw the too-small notice instead, but clipping beats overflowing at any size. + return fitLine(model.ShortHelpView(bindings[:1]), width) +} + +// fullView renders the expanded help, dropping trailing columns until it fits. Columns whose +// bindings are all out of scope drop out on their own inside help.Model. +func (h HelpFooter) fullView(groups [][]key.Binding) string { + width := h.model.Width() + model := h.unlimited() + + view := model.FullHelpView(groups) + if width <= 0 || lipgloss.Width(view) <= width { + return view + } + for count := len(groups) - 1; count > 0; count-- { + candidate := model.FullHelpView(groups[:count]) + if lipgloss.Width(candidate) <= width { + return candidate + } + } + // Narrower than a single column. Clipping beats overflowing, and a terminal this narrow + // is showing the too-small notice anyway. + return clipLines(model.FullHelpView(groups[:1]), width) +} + +// clipLines clips every line of a block to width, measured in display cells so that styled +// text and wide glyphs are counted correctly. Unlike fitLine it does not pad, because a +// column of help is not a full-width row. +func clipLines(block string, width int) string { + lines := strings.Split(block, "\n") + clip := lipgloss.NewStyle().MaxWidth(max(width, 1)) + for i, line := range lines { + if lipgloss.Width(line) > width { + lines[i] = clip.Render(line) + } + } + return strings.Join(lines, "\n") +} diff --git a/plc4go/tools/internal/tui/keys_test.go b/plc4go/tools/internal/tui/keys_test.go new file mode 100644 index 000000000..9d0d9d648 --- /dev/null +++ b/plc4go/tools/internal/tui/keys_test.go @@ -0,0 +1,307 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package tui + +import ( + "strconv" + "strings" + "testing" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// charKey builds the key press a terminal sends for a printable character. Text has to be set +// as well as Code, because uv.Key.String prefers Text and that is what key.Matches compares. +func charKey(r rune) tea.KeyPressMsg { + return tea.KeyPressMsg{Code: r, Text: string(r)} +} + +// namedKey builds the key press for a named key, which carries no text. +func namedKey(code rune) tea.KeyPressMsg { + return tea.KeyPressMsg{Code: code} +} + +// ctrlKey builds a ctrl-modified key press. +func ctrlKey(r rune) tea.KeyPressMsg { + return tea.KeyPressMsg{Code: r, Mod: tea.ModCtrl} +} + +// bareBindings are the single-character shortcuts that the design review found offered +// alongside a prompt that has focus almost all the time. Each one must be inert at the prompt +// and live in a pane; that is the whole point of the scoping rule. +var bareBindings = []struct { + name string + msg tea.KeyPressMsg + pick func(KeyMap) key.Binding +}{ + {"quit", charKey('q'), func(k KeyMap) key.Binding { return k.Quit }}, + {"help", charKey('?'), func(k KeyMap) key.Binding { return k.Help }}, + {"pane 1", charKey('1'), func(k KeyMap) key.Binding { return k.PaneJump }}, + {"pane 4", charKey('4'), func(k KeyMap) key.Binding { return k.PaneJump }}, + {"focus prompt", charKey('i'), func(k KeyMap) key.Binding { return k.FocusPrompt }}, + {"focus prompt colon", charKey(':'), func(k KeyMap) key.Binding { return k.FocusPrompt }}, + {"move down", charKey('j'), func(k KeyMap) key.Binding { return k.Down }}, + {"move up", charKey('k'), func(k KeyMap) key.Binding { return k.Up }}, + {"top", charKey('g'), func(k KeyMap) key.Binding { return k.Top }}, + {"bottom", charKey('G'), func(k KeyMap) key.Binding { return k.Bottom }}, + {"filter", charKey('/'), func(k KeyMap) key.Binding { return k.Filter }}, + {"wrap", charKey('w'), func(k KeyMap) key.Binding { return k.Wrap }}, + {"yank", charKey('y'), func(k KeyMap) key.Binding { return k.Yank }}, + {"follow", charKey('f'), func(k KeyMap) key.Binding { return k.Follow }}, + {"expand", charKey('e'), func(k KeyMap) key.Binding { return k.Expand }}, + {"log level", charKey('L'), func(k KeyMap) key.Binding { return k.LogLevel }}, +} + +func TestBareCharacterBindingsAreInertWhileTextHasFocus(t *testing.T) { + for _, binding := range bareBindings { + t.Run(binding.name, func(t *testing.T) { + pane := NewKeyMap().ForFocus(FocusPane) + assert.True(t, key.Matches(binding.msg, binding.pick(pane)), + "%q must be live in a pane", binding.msg.String()) + + for _, focus := range []Focus{FocusPrompt, FocusOverlay} { + resolved := NewKeyMap().ForFocus(focus) + assert.False(t, key.Matches(binding.msg, binding.pick(resolved)), + "%q must be inert at %s focus", binding.msg.String(), focus) + } + }) + } +} + +func TestTypingACommandNameAtThePromptFiresNoPaneAction(t *testing.T) { + // The concrete failure the scoping rule exists to prevent: the word "read" is r, e, a and + // d, three of which were proposed as pane shortcuts. + prompt := NewKeyMap().ForFocus(FocusPrompt) + paneActions := []key.Binding{ + prompt.Quit, prompt.Help, prompt.PaneJump, prompt.FocusPrompt, + prompt.NextPane, prompt.PrevPane, prompt.Up, prompt.Down, + prompt.Top, prompt.Bottom, prompt.Filter, prompt.Wrap, + prompt.Yank, prompt.Follow, prompt.Expand, prompt.LogLevel, + } + + for _, line := range []string{"read", "write", "browse", "subscribe", "quit", "disconnect"} { + for _, r := range line { + assert.False(t, key.Matches(charKey(r), paneActions...), + "typing %q in %q fired a pane action", r, line) + } + } +} + +func TestPromptOnlyBindingsAreInertInAPane(t *testing.T) { + pane := NewKeyMap().ForFocus(FocusPane) + + assert.False(t, key.Matches(namedKey(tea.KeyUp), pane.HistoryPrev), "up must not be history in a pane") + assert.False(t, key.Matches(namedKey(tea.KeyDown), pane.HistoryNext), "down must not be history in a pane") + assert.False(t, key.Matches(namedKey(tea.KeyTab), pane.Complete), "tab must not complete in a pane") + assert.False(t, key.Matches(ctrlKey('n'), pane.SuggestNext), "ctrl+n must not be a suggestion key in a pane") + + // The same keys still do the pane's own job, which is why they cannot be shared. + assert.True(t, key.Matches(namedKey(tea.KeyUp), pane.Up)) + assert.True(t, key.Matches(namedKey(tea.KeyTab), pane.NextPane)) +} + +func TestQuitStaysReachableFromEveryFocus(t *testing.T) { + // Losing the way out is worse than losing any shortcut, so ctrl+c is global and q is only + // the convenience. + for _, focus := range []Focus{FocusPrompt, FocusPane, FocusOverlay} { + t.Run(focus.String(), func(t *testing.T) { + resolved := NewKeyMap().ForFocus(focus) + assert.True(t, key.Matches(ctrlKey('c'), resolved.Quit), "ctrl+c must quit at %s focus", focus) + assert.True(t, key.Matches(namedKey(tea.KeyF1), resolved.Help), "f1 must open help at %s focus", focus) + assert.True(t, key.Matches(namedKey(tea.KeyEsc), resolved.Cancel), "esc must cancel at %s focus", focus) + }) + } +} + +func TestTextEntryFocusesKeepTheKeysNeededToTypeAndRun(t *testing.T) { + for _, focus := range []Focus{FocusPrompt, FocusOverlay} { + t.Run(focus.String(), func(t *testing.T) { + resolved := NewKeyMap().ForFocus(focus) + assert.True(t, key.Matches(namedKey(tea.KeyEnter), resolved.Submit)) + }) + } + // enter in a pane means "open this row", which is the pane's business, not the prompt's. + assert.False(t, key.Matches(namedKey(tea.KeyEnter), NewKeyMap().ForFocus(FocusPane).Submit)) +} + +func TestForFocusDoesNotCarryBindingsOverFromThePreviousFocus(t *testing.T) { + // ForFocus resolves from the binding table rather than from the receiver, so a keymap + // that has been through a pane cannot leak a bare letter back to the prompt. + roundTripped := NewKeyMap().ForFocus(FocusPane).ForFocus(FocusPrompt) + + assert.Equal(t, FocusPrompt, roundTripped.Focus()) + assert.False(t, key.Matches(charKey('w'), roundTripped.Wrap)) + assert.Equal(t, NewKeyMap().ShortHelp(), roundTripped.ShortHelp()) +} + +func TestScopeLiveMatchesTheFocusItNames(t *testing.T) { + for _, testCase := range []struct { + scope Scope + live map[Focus]bool + }{ + {ScopeGlobal, map[Focus]bool{FocusPrompt: true, FocusPane: true, FocusOverlay: true}}, + {ScopePane, map[Focus]bool{FocusPrompt: false, FocusPane: true, FocusOverlay: false}}, + {ScopeInput, map[Focus]bool{FocusPrompt: true, FocusPane: false, FocusOverlay: true}}, + {ScopePrompt, map[Focus]bool{FocusPrompt: true, FocusPane: false, FocusOverlay: false}}, + } { + t.Run(testCase.scope.String(), func(t *testing.T) { + for focus, want := range testCase.live { + assert.Equal(t, want, testCase.scope.Live(focus), "scope %s at %s focus", testCase.scope, focus) + } + }) + } +} + +func TestScopedGivesAToolBindingTheSameFocusScoping(t *testing.T) { + // A tool-specific binding, such as the pcap analyzer's [b]ytes toggle, must go inert at + // the prompt without the tool comparing focus values itself. + bytesToggle := key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "bytes")) + + assert.True(t, key.Matches(charKey('b'), Scoped(bytesToggle, ScopePane, FocusPane))) + assert.False(t, key.Matches(charKey('b'), Scoped(bytesToggle, ScopePane, FocusPrompt))) + assert.False(t, key.Matches(charKey('b'), Scoped(bytesToggle, ScopePane, FocusOverlay))) + // A binding declared global stays live, which is what makes the distinction meaningful. + assert.True(t, key.Matches(charKey('b'), Scoped(bytesToggle, ScopeGlobal, FocusPrompt))) +} + +// TestHelpFooterStillAdvertisesQuitAtNarrowWidths pins the reason quit is first in ShortHelp. +// bubbles/help renders items until they no longer fit and then ellipsizes the rest, so the +// LAST binding is the one that silently disappears. At eighty columns, the width the tools +// have to work at, that used to be the quit binding. +func TestHelpFooterStillAdvertisesQuitAtNarrowWidths(t *testing.T) { + theme := NewTheme(Options{NoColor: true, ASCII: true}) + + for _, focus := range []Focus{FocusPrompt, FocusPane, FocusOverlay} { + for _, width := range []int{120, 80, 60, 40, 30, 20} { + t.Run(focus.String()+"/w"+strconv.Itoa(width), func(t *testing.T) { + footer := NewHelpFooter(theme) + footer.SetWidth(width) + + view := footer.View(NewKeyMap().ForFocus(focus)) + + require.NotEmpty(t, view) + assert.Contains(t, view, "quit", "the footer must still advertise the way out") + assert.LessOrEqual(t, lipgloss.Width(view), width, "the footer must not overflow its width") + assert.NotContains(t, view, "\n", "the short footer is a single line") + }) + } + } +} + +// TestHelpFooterNeverOverflowsItsWidth is the regression test for a defect in bubbles/help +// itself. help.Model.shouldAddItem truncates only while there is room for its own ellipsis; +// once there is not, it falls through, adds the item anyway and keeps going, so the footer +// grows without bound. Left alone that wraps and pushes every row of the layout down one, +// which is precisely the class of breakage the responsive rules exist to prevent. +func TestHelpFooterNeverOverflowsItsWidth(t *testing.T) { + theme := NewTheme(Options{NoColor: true, ASCII: true}) + + for _, focus := range []Focus{FocusPrompt, FocusPane, FocusOverlay} { + for width := 1; width <= 130; width++ { + footer := NewHelpFooter(theme) + footer.SetWidth(width) + + short := footer.View(NewKeyMap().ForFocus(focus)) + require.LessOrEqual(t, lipgloss.Width(short), width, + "short footer at %s focus overflowed width %d: %q", focus, width, short) + require.NotContains(t, short, "\n") + + footer.SetShowAll(true) + expanded := footer.View(NewKeyMap().ForFocus(focus)) + for line := range strings.SplitSeq(expanded, "\n") { + require.LessOrEqual(t, lipgloss.Width(line), width, + "expanded help at %s focus overflowed width %d", focus, width) + } + } + } +} + +func TestHelpFooterOmitsBindingsThatAreOutOfScope(t *testing.T) { + theme := NewTheme(Options{NoColor: true, ASCII: true}) + footer := NewHelpFooter(theme) + footer.SetWidth(200) + + prompt := footer.View(NewKeyMap().ForFocus(FocusPrompt)) + assert.Contains(t, prompt, "complete") + assert.NotContains(t, prompt, "wrap", "a pane action must not be advertised while the prompt has focus") + + footer.SetShowAll(true) + expanded := footer.View(NewKeyMap().ForFocus(FocusPrompt)) + assert.Contains(t, expanded, "history") + assert.NotContains(t, expanded, "yank", "the expanded help must describe what this focus can do, not everything") + + expandedPane := footer.View(NewKeyMap().ForFocus(FocusPane)) + assert.Contains(t, expandedPane, "yank") + assert.NotContains(t, expandedPane, "complete") +} + +func TestHelpFooterUsesTheThemeGlyphsSoItDegradesWithTheRestOfTheUI(t *testing.T) { + unicode := NewHelpFooter(NewTheme(Options{NoColor: true})) + ascii := NewHelpFooter(NewTheme(Options{NoColor: true, ASCII: true})) + unicode.SetWidth(200) + ascii.SetWidth(200) + + keys := NewKeyMap() + assert.Contains(t, unicode.View(keys), UnicodeGlyphs().Separator) + assert.Contains(t, ascii.View(keys), ASCIIGlyphs().Separator) + assert.NotContains(t, ascii.View(keys), UnicodeGlyphs().Separator) +} + +func TestHelpLabelsCarryNoWideGlyphsThatCouldMismeasureTheFooter(t *testing.T) { + // The footer is the most width-critical line in either UI and the one place where a glyph + // a font measures as two cells would eat a binding. Its labels are therefore ASCII. + for _, focus := range []Focus{FocusPrompt, FocusPane, FocusOverlay} { + for _, binding := range NewKeyMap().ForFocus(focus).ShortHelp() { + label := binding.Help().Key + binding.Help().Desc + for _, r := range label { + require.Less(t, r, rune(0x80), "help label %q at %s focus is not ASCII", label, focus) + } + assert.Equal(t, len(label), lipgloss.Width(label), "help label %q must measure one cell per byte", label) + } + } +} + +func TestShortHelpLeadsWithQuitInEveryFocus(t *testing.T) { + for _, focus := range []Focus{FocusPrompt, FocusPane, FocusOverlay} { + t.Run(focus.String(), func(t *testing.T) { + short := NewKeyMap().ForFocus(focus).ShortHelp() + require.NotEmpty(t, short) + assert.Equal(t, "quit", short[0].Help().Desc, + "quit must be first, because help ellipsizes the tail") + }) + } +} + +func TestShortHelpAdvertisesOnlyBindingsThatActuallyWork(t *testing.T) { + // A footer that names a key which does nothing is worse than a shorter footer. + for _, focus := range []Focus{FocusPrompt, FocusPane, FocusOverlay} { + t.Run(focus.String(), func(t *testing.T) { + for _, binding := range NewKeyMap().ForFocus(focus).ShortHelp() { + assert.True(t, binding.Enabled(), + "%q is advertised at %s focus but has no keys", binding.Help().Desc, focus) + assert.NotEmpty(t, strings.TrimSpace(binding.Help().Key)) + } + }) + } +} diff --git a/plc4go/tools/internal/tui/layout.go b/plc4go/tools/internal/tui/layout.go new file mode 100644 index 000000000..4a899866c --- /dev/null +++ b/plc4go/tools/internal/tui/layout.go @@ -0,0 +1,196 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package tui + +import "strconv" + +// Responsive behaviour for the terminal tools. +// +// This exists because of a specific, verified defect in the UIs being replaced: below 100 +// columns they added the command area to their grid with a zero row and column span, which +// the layout engine silently discards, so the input field was never drawn and the user typed +// blind. The rule that follows from that is the one invariant here: whatever else is dropped +// as space runs out, the prompt is never dropped and never clipped. + +// Breakpoints. These are display cells. +const ( + // CompactWidth is the width below which the layout collapses to a single column. + CompactWidth = 90 + // CrampedHeight is the height below which optional panes start being dropped. + CrampedHeight = 14 + // MinWidth and MinHeight are the smallest terminal the tools attempt to draw in. + MinWidth = 60 + MinHeight = 12 +) + +// Mode is the layout regime a terminal size falls into. +type Mode int + +const ( + // ModeWide is the full multi-column layout. + ModeWide Mode = iota + // ModeCompact is a single column, with the sidebar reachable as an overlay. + ModeCompact + // ModeTooSmall is a terminal too small to draw anything useful in. + ModeTooSmall +) + +// String makes Mode readable in test failures and logs. +func (m Mode) String() string { + switch m { + case ModeWide: + return "wide" + case ModeCompact: + return "compact" + case ModeTooSmall: + return "too-small" + default: + return "unknown" + } +} + +// Size is a terminal size in cells. +type Size struct { + Width int + Height int +} + +// String renders a size as WxH, for subtest names and log lines. +func (s Size) String() string { + return strconv.Itoa(s.Width) + "x" + strconv.Itoa(s.Height) +} + +// Layout is the resolved geometry for one terminal size. +type Layout struct { + Size Size + Mode Mode + + // SidebarWidth is the width of the left column, zero in compact mode. + SidebarWidth int + // MainWidth is the width of the primary column. + MainWidth int + // DetailWidth is the width of the trailing detail column, zero when it is not shown. + DetailWidth int + + // ShowSidebar, ShowDetail and ShowLog say which optional regions fit. + ShowSidebar bool + ShowDetail bool + ShowLog bool + + // BodyHeight is the height available to the panes, after the status bar and the pinned + // prompt rows have been reserved. + BodyHeight int + // LogHeight is the height of the log drawer, zero when it is not shown. + LogHeight int +} + +// Rows that are always reserved, in every mode: the status bar, the toast line, the prompt +// and the help footer. These are the rows the prompt lives among, so they come off the top of +// the budget rather than being squeezed out of it. +const ( + statusRows = 1 + toastRows = 1 + promptRows = 1 + helpRows = 1 + + // ReservedRows is the total chrome the body never gets to use. + ReservedRows = statusRows + toastRows + promptRows + helpRows +) + +// Compute resolves the layout for a terminal size. +func Compute(size Size) Layout { + layout := Layout{Size: size} + + if size.Width < MinWidth || size.Height < MinHeight { + layout.Mode = ModeTooSmall + return layout + } + + layout.BodyHeight = size.Height - ReservedRows + if layout.BodyHeight < 1 { + layout.Mode = ModeTooSmall + return layout + } + + if size.Width < CompactWidth { + layout.Mode = ModeCompact + layout.MainWidth = size.Width + // In compact mode the sidebar and the detail pane become overlays reached by a key, + // rather than columns competing for width that is not there. + layout.ShowSidebar = false + layout.ShowDetail = false + // The log drawer is the first thing to go when rows are scarce. + layout.ShowLog = size.Height >= CrampedHeight + layout.LogHeight = logHeightFor(layout) + layout.BodyHeight -= layout.LogHeight + return layout + } + + layout.Mode = ModeWide + layout.ShowSidebar = true + // Detail is the second thing to go: the main list stays useful without it. + layout.ShowDetail = size.Height >= CrampedHeight + layout.ShowLog = size.Height >= CrampedHeight + layout.LogHeight = logHeightFor(layout) + layout.BodyHeight -= layout.LogHeight + + layout.SidebarWidth = clamp(size.Width/4, 22, 34) + remaining := size.Width - layout.SidebarWidth + if layout.ShowDetail { + // Give the detail column a little under half of what is left, so the primary list + // keeps the larger share. + layout.DetailWidth = clamp(remaining*45/100, 30, 52) + layout.MainWidth = remaining - layout.DetailWidth + } else { + layout.MainWidth = remaining + } + return layout +} + +// logHeightFor sizes the log drawer as a fraction of the terminal, within sane bounds. +// +// The cap used to be eight rows at any height, which on a tall terminal was the wrong way +// round: the panes were handed thirty-eight rows they could not fill while the log -- the one +// region whose content actually grows -- stayed at eight and scrolled. A quarter of the screen +// up to sixteen rows keeps a short terminal's panes intact while letting a tall one put its +// spare rows where there is something to read. +func logHeightFor(layout Layout) int { + if !layout.ShowLog { + return 0 + } + return clamp(layout.Size.Height/4, 4, 16) +} + +// TooSmallMessage is what to render when the terminal cannot be drawn in. It states the +// requirement rather than just refusing, so the user knows what to do. +func TooSmallMessage() string { + return "terminal too small" +} + +// clamp constrains value to [lowest, highest]. +func clamp(value, lowest, highest int) int { + if value < lowest { + return lowest + } + if value > highest { + return highest + } + return value +} diff --git a/plc4go/tools/internal/tui/layout_test.go b/plc4go/tools/internal/tui/layout_test.go new file mode 100644 index 000000000..f2ad804ee --- /dev/null +++ b/plc4go/tools/internal/tui/layout_test.go @@ -0,0 +1,235 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package tui + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestPromptRowsAreAlwaysReserved is the regression test for the defect that motivated this +// package: below 100 columns the old UIs never drew their command input, so the user typed +// into an invisible field. Whatever the size, the rows the prompt lives in are taken off the +// budget before any pane gets space. +func TestPromptRowsAreAlwaysReserved(t *testing.T) { + for _, size := range []Size{ + {Width: 200, Height: 60}, + {Width: 100, Height: 24}, + {Width: 90, Height: 24}, + {Width: 89, Height: 24}, + {Width: 80, Height: 24}, + {Width: 70, Height: 20}, + {Width: 60, Height: 12}, + } { + t.Run(size.String(), func(t *testing.T) { + layout := Compute(size) + require.NotEqual(t, ModeTooSmall, layout.Mode, "%v should be drawable", size) + assert.LessOrEqual(t, layout.BodyHeight+layout.LogHeight+ReservedRows, size.Height, + "the body plus chrome must fit inside the terminal") + assert.Positive(t, layout.BodyHeight, "there must be room left for content") + }) + } +} + +// TestEightyByTwentyFourIsUsable pins the size the redesign is most accountable for, since it +// is where the previous tools were broken. +func TestEightyByTwentyFourIsUsable(t *testing.T) { + layout := Compute(Size{Width: 80, Height: 24}) + assert.Equal(t, ModeCompact, layout.Mode, "80 columns is below the compact breakpoint") + assert.Equal(t, 80, layout.MainWidth, "the single column should use the full width") + assert.False(t, layout.ShowSidebar, "a sidebar does not fit; it becomes an overlay") + assert.Positive(t, layout.BodyHeight) +} + +func TestHundredByTwentyFourIsTheWideLayout(t *testing.T) { + layout := Compute(Size{Width: 100, Height: 24}) + require.Equal(t, ModeWide, layout.Mode) + assert.True(t, layout.ShowSidebar) + assert.True(t, layout.ShowDetail) + // The columns must exactly tile the width: a rounding error here shows up as a torn edge. + assert.Equal(t, 100, layout.SidebarWidth+layout.MainWidth+layout.DetailWidth, + "columns must sum to the terminal width") +} + +// TestColumnsAlwaysTileTheWidth is the same property across a sweep of sizes, since an +// off-by-one in the column arithmetic is the classic way a TUI ends up with a ragged edge. +func TestColumnsAlwaysTileTheWidth(t *testing.T) { + for width := CompactWidth; width <= 240; width++ { + layout := Compute(Size{Width: width, Height: 40}) + require.Equal(t, ModeWide, layout.Mode) + assert.Equal(t, width, layout.SidebarWidth+layout.MainWidth+layout.DetailWidth, + "width %d: columns must tile exactly", width) + assert.Positive(t, layout.MainWidth, "width %d: the main column must not vanish", width) + } +} + +func TestCompactBreakpointIsExact(t *testing.T) { + assert.Equal(t, ModeCompact, Compute(Size{Width: CompactWidth - 1, Height: 30}).Mode) + assert.Equal(t, ModeWide, Compute(Size{Width: CompactWidth, Height: 30}).Mode) +} + +// TestPanesAreDroppedInPriorityOrder pins the degradation order: the log drawer goes before +// the detail pane, and neither takes the prompt with it. +func TestPanesAreDroppedInPriorityOrder(t *testing.T) { + roomy := Compute(Size{Width: 120, Height: 40}) + require.True(t, roomy.ShowLog) + require.True(t, roomy.ShowDetail) + + cramped := Compute(Size{Width: 120, Height: CrampedHeight - 1}) + assert.False(t, cramped.ShowLog, "the log drawer is the first thing to go") + assert.False(t, cramped.ShowDetail, "the detail pane is the second") + assert.Positive(t, cramped.BodyHeight, "the main list must survive") + assert.Equal(t, 120, cramped.SidebarWidth+cramped.MainWidth, + "with no detail column the remaining two must still tile") +} + +func TestTerminalsBelowTheMinimumAreRefusedRatherThanDrawnBadly(t *testing.T) { + for _, size := range []Size{ + {Width: 59, Height: 24}, + {Width: 80, Height: 11}, + {Width: 10, Height: 3}, + {Width: 0, Height: 0}, + } { + t.Run(size.String(), func(t *testing.T) { + assert.Equal(t, ModeTooSmall, Compute(size).Mode) + }) + } + assert.NotEmpty(t, TooSmallMessage()) +} + +func TestSidebarWidthIsClamped(t *testing.T) { + narrow := Compute(Size{Width: CompactWidth, Height: 40}) + assert.GreaterOrEqual(t, narrow.SidebarWidth, 22, "a sidebar narrower than this cannot show a connection string") + + wide := Compute(Size{Width: 400, Height: 40}) + assert.LessOrEqual(t, wide.SidebarWidth, 34, "the sidebar must not hog a very wide terminal") +} + +func TestModeStringIsReadable(t *testing.T) { + assert.Equal(t, "wide", ModeWide.String()) + assert.Equal(t, "compact", ModeCompact.String()) + assert.Equal(t, "too-small", ModeTooSmall.String()) +} + +// --- Pane --- + +// TestPaneRendersExactlyItsRequestedBox is the property every multi-pane layout depends on: +// a pane that renders one cell too wide or one line too tall corrupts its neighbours. +func TestPaneRendersExactlyItsRequestedBox(t *testing.T) { + theme := NewTheme(Options{Dark: true, NoColor: true}) + for _, tc := range []struct{ w, h int }{ + {40, 10}, {24, 5}, {80, 24}, {12, 3}, {100, 6}, + } { + t.Run(Size{Width: tc.w, Height: tc.h}.String(), func(t *testing.T) { + pane := Pane{Title: "Messages", Status: "14", Width: tc.w, Height: tc.h} + rendered := pane.Render(theme, "one\ntwo\nthree") + + lines := strings.Split(rendered, "\n") + assert.Len(t, lines, tc.h, "a pane must render exactly its height") + for i, line := range lines { + assert.Equal(t, tc.w, lipgloss.Width(line), + "line %d must be exactly %d cells, got %d: %q", i, tc.w, lipgloss.Width(line), line) + } + }) + } +} + +// TestPaneClipsOverlongContent guards against content pushing a pane out of shape. +func TestPaneClipsOverlongContent(t *testing.T) { + theme := NewTheme(Options{Dark: true, NoColor: true}) + long := strings.Repeat("this line is far too long to fit ", 20) + tall := strings.Repeat("row\n", 100) + + pane := Pane{Title: "T", Width: 30, Height: 6} + rendered := pane.Render(theme, long+"\n"+tall) + + lines := strings.Split(rendered, "\n") + assert.Len(t, lines, 6) + for i, line := range lines { + assert.Equal(t, 30, lipgloss.Width(line), "line %d overflowed: %q", i, line) + } +} + +func TestPaneShowsItsTitleAndStatus(t *testing.T) { + theme := NewTheme(Options{Dark: true, NoColor: true}) + rendered := Pane{Title: "Connections", Status: "2", Width: 40, Height: 5}.Render(theme, "") + top, _, _ := strings.Cut(rendered, "\n") + assert.Contains(t, top, "Connections", "the title belongs in the top border") + assert.Contains(t, top, "2", "the status belongs in the top border") +} + +// TestPaneDropsStatusBeforeTitleWhenNarrow: when the border cannot hold both, the count is +// the expendable half, and the result must still be a well-formed box. +func TestPaneDropsStatusBeforeTitleWhenNarrow(t *testing.T) { + theme := NewTheme(Options{Dark: true, NoColor: true}) + rendered := Pane{Title: "Connections", Status: "1234567890", Width: 18, Height: 4}.Render(theme, "") + top, _, _ := strings.Cut(rendered, "\n") + assert.Contains(t, top, "Connections") + assert.NotContains(t, top, "1234567890") + assert.Equal(t, 18, lipgloss.Width(top)) +} + +// TestPaneWithATitleTooLongStaysAWellFormedBox is the pathological case. +func TestPaneWithATitleTooLongStaysAWellFormedBox(t *testing.T) { + theme := NewTheme(Options{Dark: true, NoColor: true}) + rendered := Pane{Title: strings.Repeat("very long title ", 5), Width: 14, Height: 4}.Render(theme, "x") + for i, line := range strings.Split(rendered, "\n") { + assert.Equal(t, 14, lipgloss.Width(line), "line %d: %q", i, line) + } +} + +// TestPaneGeometryIsIdenticalAcrossGlyphSets is the payoff of the width-parity rule: choosing +// the ASCII fallback must not change the layout by a single cell. +func TestPaneGeometryIsIdenticalAcrossGlyphSets(t *testing.T) { + unicode := NewTheme(Options{Dark: true, NoColor: true, ASCII: false}) + ascii := NewTheme(Options{Dark: true, NoColor: true, ASCII: true}) + + pane := Pane{Title: "Messages", Status: "14", Width: 44, Height: 8} + unicodeLines := strings.Split(pane.Render(unicode, "a\nb\nc"), "\n") + asciiLines := strings.Split(pane.Render(ascii, "a\nb\nc"), "\n") + + require.Len(t, asciiLines, len(unicodeLines)) + for i := range unicodeLines { + assert.Equal(t, lipgloss.Width(unicodeLines[i]), lipgloss.Width(asciiLines[i]), + "line %d differs in width between glyph sets", i) + } +} + +func TestPaneFocusChangesAppearanceNotGeometry(t *testing.T) { + theme := NewTheme(Options{Dark: true}) + base := Pane{Title: "Messages", Width: 30, Height: 5} + focused := base + focused.Focused = true + + plain := base.Render(theme, "x") + lit := focused.Render(theme, "x") + + assert.NotEqual(t, plain, lit, "focus must be visible") + plainLines, litLines := strings.Split(plain, "\n"), strings.Split(lit, "\n") + require.Len(t, litLines, len(plainLines)) + for i := range plainLines { + assert.Equal(t, lipgloss.Width(plainLines[i]), lipgloss.Width(litLines[i]), + "focus must not change geometry, line %d", i) + } +} diff --git a/plc4go/tools/internal/tui/pane.go b/plc4go/tools/internal/tui/pane.go new file mode 100644 index 000000000..3101e48b4 --- /dev/null +++ b/plc4go/tools/internal/tui/pane.go @@ -0,0 +1,159 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package tui + +import ( + "strings" + + "charm.land/lipgloss/v2" +) + +// Pane draws a bordered region whose name lives in its top border. +// +// The name is in the border rather than on a line of its own for a measured reason: the UIs +// being replaced titled their panes with a centred text widget occupying a full three-row grid +// cell, so four titles cost twelve rows of chrome to display four words. Carrying the title in +// the border costs nothing and leaves those rows for content. +type Pane struct { + // Title is the pane's name, drawn at the left of the top border. + Title string + // Status is optional text drawn at the right of the top border, for a count or a mode. + Status string + // Width and Height are the pane's outer dimensions, borders included. + Width int + Height int + // Focused selects the focused styling, so the keyboard's target is always visible. + Focused bool +} + +// Render draws the pane around content. +// +// content is clipped to the inner area rather than being allowed to overflow, because a pane +// that overflows corrupts every pane drawn beside it. +func (p Pane) Render(theme Theme, content string) string { + border := theme.Glyphs.Border + // Chrome, not PaneStyle: PaneStyle carries a Border, so rendering a single border glyph + // with it would draw a whole box around that one glyph. + chromeStyle := theme.ChromeStyle(p.Focused) + titleStyle := theme.PaneTitleStyle(p.Focused) + + // Two columns and two rows go to the border itself. + innerWidth := max(p.Width-2, 1) + innerHeight := max(p.Height-2, 1) + + // edge colours a run of border characters. + edge := func(s string) string { + if s == "" { + return "" + } + return chromeStyle.Render(s) + } + + var out strings.Builder + out.WriteString(p.topBorder(theme, border, edge, titleStyle, innerWidth)) + out.WriteString("\n") + + lines := fitLines(content, innerWidth, innerHeight) + for _, line := range lines { + out.WriteString(edge(border.Left)) + out.WriteString(line) + out.WriteString(edge(border.Right)) + out.WriteString("\n") + } + + out.WriteString(edge(border.BottomLeft)) + out.WriteString(edge(strings.Repeat(border.Bottom, innerWidth))) + out.WriteString(edge(border.BottomRight)) + return out.String() +} + +// topBorder builds the top edge, embedding the title and any status text. +func (p Pane) topBorder(theme Theme, border lipgloss.Border, edge func(string) string, titleStyle lipgloss.Style, innerWidth int) string { + // A single border cell of padding either side of the title keeps it from touching the + // corner glyphs. + title := "" + if p.Title != "" { + title = titleStyle.Render(p.Title) + } + status := "" + if p.Status != "" { + status = theme.Muted.Render(p.Status) + } + + titleWidth := lipgloss.Width(title) + statusWidth := lipgloss.Width(status) + + // Padding: one border char before the title, one after; likewise around the status. + const gaps = 4 + if titleWidth+statusWidth+gaps > innerWidth { + // Not enough room for both: the status is the expendable half. + status, statusWidth = "", 0 + if titleWidth+2 > innerWidth { + // Not even the title fits; draw a plain edge rather than a broken one. + return edge(border.TopLeft) + edge(strings.Repeat(border.Top, innerWidth)) + edge(border.TopRight) + } + } + + fill := max(innerWidth-titleWidth-statusWidth-2, 0) + + var out strings.Builder + out.WriteString(edge(border.TopLeft)) + out.WriteString(edge(border.Top)) + if title != "" { + out.WriteString(title) + } + out.WriteString(edge(strings.Repeat(border.Top, fill))) + if status != "" { + out.WriteString(status) + } + out.WriteString(edge(border.Top)) + out.WriteString(edge(border.TopRight)) + return out.String() +} + +// fitLines forces content into exactly height lines of exactly width cells, truncating what +// is too long and padding what is too short. +func fitLines(content string, width, height int) []string { + raw := strings.Split(content, "\n") + out := make([]string, 0, height) + for i := range height { + line := "" + if i < len(raw) { + line = raw[i] + } + out = append(out, fitLine(line, width)) + } + return out +} + +// fitLine truncates or pads a single line to exactly width cells, measuring in display cells +// so that styled text and wide glyphs are handled correctly. +func fitLine(line string, width int) string { + actual := lipgloss.Width(line) + switch { + case actual == width: + return line + case actual < width: + return line + strings.Repeat(" ", width-actual) + default: + // Truncate to width. lipgloss keeps the styling intact while cutting to a cell count. + return lipgloss.NewStyle().MaxWidth(width).Render(line) + } +} diff --git a/plc4go/tools/internal/tui/prompt.go b/plc4go/tools/internal/tui/prompt.go new file mode 100644 index 000000000..db9ba6a93 --- /dev/null +++ b/plc4go/tools/internal/tui/prompt.go @@ -0,0 +1,608 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package tui + +import ( + "strconv" + "strings" + "time" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" +) + +// The command line both terminal tools are driven from. +// +// Three things about it were dictated by defects in what it replaces. +// +// The field spans the whole available width. The previous UIs called SetFieldWidth(30), so +// any connection string worth typing was clipped as it was typed. +// +// Completion comes from an injected function rather than a command tree compiled in here, +// because the two tools have entirely different command sets and this package must not know +// either of them. +// +// Up and down do double duty, and the rule is stated once and tested. bubbles/v2/textinput +// binds up and down to PrevSuggestion and NextSuggestion by default (textinput.DefaultKeyMap, +// verified in v2.2.1), and those bindings are live whenever ShowSuggestions is set and there +// are matches. The sketched UI also wanted up and down for command history. Rather than +// silently letting one win, the completion list has an explicit open state: while it is open +// up and down walk the completion list, and while it is closed they walk the history. +// ctrl+p and ctrl+n always walk the completion list, so there is a pair that never changes +// meaning. + +// SuggestFunc produces completion candidates for the text currently in the prompt. +// +// The candidates are whole replacement lines, not fragments: textinput matches a candidate by +// testing it against the entire value as a case-insensitive prefix, so returning "tag1" for +// the line "read ta" would match nothing. Return "read tag1". +// +// It is called on every content change, so it has to be cheap and must not block. +type SuggestFunc func(currentText string) []string + +// PromptSubmitMsg is emitted when the user runs a line. The line has already been pushed onto +// the history and cleared from the field by the time this arrives. +type PromptSubmitMsg struct { + Line string +} + +// PromptTickMsg advances the inline spinner. Send it directly in a test; in a running program +// it comes from PromptTick. +type PromptTickMsg struct { + Time time.Time +} + +// promptSpinnerInterval is how often the inline spinner advances. Fast enough to read as +// motion, slow enough not to dominate the frame rate of a tool that is mostly idle. +const promptSpinnerInterval = 100 * time.Millisecond + +// PromptTick returns a command that advances the spinner once. A busy prompt re-issues it +// from Update, so one PromptTick after Start keeps the spinner turning. +func PromptTick() tea.Cmd { + return tea.Tick(promptSpinnerInterval, func(t time.Time) tea.Msg { + return PromptTickMsg{Time: t} + }) +} + +// DefaultHistoryLimit is how many lines the prompt remembers. Bounded because these tools are +// long-running session shells and an unbounded slice of every line ever typed is a leak, not +// a feature. +const DefaultHistoryLimit = 200 + +// Prompt is the command line. It is a component, not a tea.Model: View returns a string, +// because only the top-level model returns a tea.View. +type Prompt struct { + input textinput.Model + theme Theme + keys KeyMap + suggest SuggestFunc + + // Label is drawn ahead of the field. + Label string + + // history is oldest first. cursor indexes it while the user is walking it and equals + // len(history) when they are not; draft holds the line that was in progress when the walk + // started, so that walking back out of the history returns it rather than losing it. + history []string + cursor int + draft string + historyLimit int + + // completionOpen is explicit state rather than "are there matches", because it decides + // what up and down mean. Editing opens it, esc, tab and a history recall close it. + completionOpen bool + + // busy, startedAt and frame drive the in-flight indication. + busy bool + startedAt time.Time + frame int + // now is the clock, injected so that the elapsed indication is assertable. + now func() time.Time + + width int +} + +// NewPrompt builds a prompt. suggest may be nil, which turns completion off. +func NewPrompt(theme Theme, suggest SuggestFunc) Prompt { + input := textinput.New() + // The label is rendered by View, from Label, so that the theme owns its colour and the + // completion list can indent by exactly its width. + input.Prompt = "" + // No placeholder: the "$ " label already says what the row is, and placeholder text in a + // field that is usually focused reads as a value the user did not type. + input.Placeholder = "" + + // Every field style carries the band's background, because the row is a raised surface and + // a terminal has no way to nest one: whatever the field renders would otherwise punch a + // hole in the band the moment its own styling reset. + styles := input.Styles() + styles.Focused.Text = theme.OnSurface(theme.Value) + styles.Focused.Suggestion = theme.OnSurface(theme.Muted) + styles.Blurred.Text = theme.OnSurface(theme.Muted) + styles.Blurred.Suggestion = theme.OnSurface(theme.Muted) + styles.Focused.Placeholder = theme.OnSurface(theme.Muted) + styles.Blurred.Placeholder = theme.OnSurface(theme.Muted) + // The label is rendered by View, so the field's own prompt is empty -- but an unset style + // still emits its default, a hardcoded white, and that is a colour reaching a terminal + // that asked for none. + styles.Focused.Prompt = theme.OnSurface(theme.Muted) + styles.Blurred.Prompt = theme.OnSurface(theme.Muted) + // The cursor's own colour, too. Left at the bubbles default it is a hardcoded white, which + // is a colour reaching a terminal that asked for none, and which on the band reads as a + // stray cell rather than as a cursor. + if theme.IsNoColor() { + styles.Cursor.Color = nil + } else { + styles.Cursor.Color = theme.CursorColor() + } + input.SetStyles(styles) + + prompt := Prompt{ + input: input, + theme: theme, + keys: NewKeyMap(), + suggest: suggest, + Label: "$ ", + historyLimit: DefaultHistoryLimit, + now: time.Now, + } + return prompt +} + +// SetClock replaces the clock the elapsed indication is measured against. Tests inject a +// clock they control; nothing else should call this. +func (p *Prompt) SetClock(now func() time.Time) { + if now != nil { + p.now = now + } +} + +// SetHistoryLimit bounds the remembered history. A limit below one is ignored. +func (p *Prompt) SetHistoryLimit(limit int) { + if limit < 1 { + return + } + p.historyLimit = limit + p.trimHistory() +} + +// SetWidth sets the total width the prompt line has to fit in. +func (p *Prompt) SetWidth(width int) { + p.width = max(width, 0) + p.applyWidth() +} + +// applyWidth hands the field whatever width is left over. It runs again whenever the +// in-flight indication appears or disappears, because that indication takes width from the +// field and the field's own horizontal scrolling has to agree with what is drawn. +func (p *Prompt) applyWidth() { + p.input.SetWidth(max(p.fieldWidth(), 1)) +} + +// Width reports the total width the prompt renders at. +func (p Prompt) Width() int { return p.width } + +// Focus gives the prompt the keyboard. +func (p *Prompt) Focus() tea.Cmd { + return p.input.Focus() +} + +// Blur takes the keyboard away, and closes the completion list with it: a completion popup +// hanging under a field that no longer has focus is just a lie about where input goes. +func (p *Prompt) Blur() { + p.input.Blur() + p.completionOpen = false +} + +// Focused reports whether the prompt has the keyboard. +func (p Prompt) Focused() bool { return p.input.Focused() } + +// Value is the line currently in the field. +func (p Prompt) Value() string { return p.input.Value() } + +// SetValue replaces the line in the field, as a tool does when it pre-fills a command. +func (p *Prompt) SetValue(value string) { + p.input.SetValue(value) + p.input.CursorEnd() + p.syncCompletion(false) +} + +// Reset clears the field without touching the history. +func (p *Prompt) Reset() { + p.input.Reset() + p.cursor = len(p.history) + p.draft = "" + p.syncCompletion(false) +} + +// History returns the remembered lines, oldest first. The copy is deliberate: the caller must +// not be able to reach in and edit the prompt's state. +func (p Prompt) History() []string { + out := make([]string, len(p.history)) + copy(out, p.history) + return out +} + +// CompletionOpen reports whether the completion list is showing. This is what decides whether +// up and down mean completion or history. +func (p Prompt) CompletionOpen() bool { return p.completionOpen } + +// Completions are the candidates the completion list is showing, in order. +func (p Prompt) Completions() []string { + if !p.completionOpen { + return nil + } + return p.input.MatchedSuggestions() +} + +// CompletionIndex is the position of the highlighted candidate in Completions. +func (p Prompt) CompletionIndex() int { return p.input.CurrentSuggestionIndex() } + +// Start marks a command as in flight, which turns on the spinner and the elapsed indication. +// Pair it with PromptTick to make the spinner turn. +func (p *Prompt) Start() { + p.busy = true + p.startedAt = p.now() + p.frame = 0 + p.applyWidth() +} + +// Finish marks the in-flight command as done. +func (p *Prompt) Finish() { + p.busy = false + p.applyWidth() +} + +// Busy reports whether a command is in flight. +func (p Prompt) Busy() bool { return p.busy } + +// Elapsed is how long the in-flight command has been running, zero when nothing is running. +func (p Prompt) Elapsed() time.Duration { + if !p.busy { + return 0 + } + return p.now().Sub(p.startedAt) +} + +// ConsumesEscape reports whether esc belongs to the prompt right now. +// +// esc drives a chain of increasingly drastic dismissals that the top-level model owns: close +// the completion list, dismiss the toast, abort the running command, and so on. The prompt +// cannot decide that chain, but it does know when the first link is its own, so the model asks +// rather than guessing. Without this the model would act on the same esc that closed the +// completion list. +func (p Prompt) ConsumesEscape() bool { + return p.Focused() && p.completionOpen +} + +// Update handles a message. It returns the updated prompt and any command to run; running a +// line arrives at the model as a PromptSubmitMsg. +func (p Prompt) Update(msg tea.Msg) (Prompt, tea.Cmd) { + switch msg := msg.(type) { + case PromptTickMsg: + if !p.busy { + return p, nil + } + frames := p.theme.Glyphs.Spinner + if len(frames) > 0 { + p.frame = (p.frame + 1) % len(frames) + } + return p, PromptTick() + + case tea.KeyPressMsg: + if !p.Focused() { + return p, nil + } + return p.handleKey(msg) + } + + var cmd tea.Cmd + p.input, cmd = p.input.Update(msg) + return p, cmd +} + +// handleKey resolves one key press against the prompt-scoped keymap. +func (p Prompt) handleKey(msg tea.KeyPressMsg) (Prompt, tea.Cmd) { + switch { + case key.Matches(msg, p.keys.Submit): + line := strings.TrimSpace(p.input.Value()) + p.input.Reset() + p.pushHistory(line) + p.draft = "" + p.syncCompletion(false) + if line == "" { + // An empty line is not a command. It still clears the field and closes the + // completion list, which is what a user pressing enter on nothing expects. + return p, nil + } + return p, func() tea.Msg { return PromptSubmitMsg{Line: line} } + + case key.Matches(msg, p.keys.Cancel): + // Only the first link of the esc chain: closing the completion list. ConsumesEscape + // tells the model whether that happened, so it knows whether to take the next link. + if p.completionOpen { + p.completionOpen = false + } + return p, nil + + case key.Matches(msg, p.keys.Complete): + // tab is textinput's AcceptSuggestion, so accepting is forwarded rather than + // reimplemented. A closed list reopens instead, which is what makes tab useful again + // after an esc or a history recall. + if !p.completionOpen { + p.syncCompletion(true) + return p, nil + } + var cmd tea.Cmd + p.input, cmd = p.input.Update(msg) + // Accepting closes the list: the line is now the candidate, and leaving it open would + // put up and down back on completion for a word the user has finished. + p.syncCompletion(false) + return p, cmd + + case key.Matches(msg, p.keys.SuggestNext, p.keys.SuggestPrev): + // ctrl+n and ctrl+p are the unambiguous pair: they always mean the completion list. + // The first press spends itself opening the list, so that what the next press moves + // is something the user can see. + if !p.completionOpen { + p.syncCompletion(true) + return p, nil + } + var cmd tea.Cmd + p.input, cmd = p.input.Update(msg) + return p, cmd + + case key.Matches(msg, p.keys.HistoryPrev, p.keys.HistoryNext): + if p.completionOpen { + // Rule: up and down drive the completion list while it is open. textinput's own + // default bindings already put up and down on Prev/NextSuggestion, so forwarding + // the key unchanged is the whole implementation. + var cmd tea.Cmd + p.input, cmd = p.input.Update(msg) + return p, cmd + } + if key.Matches(msg, p.keys.HistoryPrev) { + p.historyBack() + } else { + p.historyForward() + } + return p, nil + } + + // Everything else is line editing. + before := p.input.Value() + var cmd tea.Cmd + p.input, cmd = p.input.Update(msg) + // An edit re-opens the completion list, because the candidates just changed and the user + // is mid-word. A key the field ignored, such as a page key aimed at a pane behind the + // prompt, must not resurrect a list the user has dismissed. + p.syncCompletion(p.input.Value() != before || p.completionOpen) + return p, cmd +} + +// pushHistory appends line, skipping blanks and an immediate repeat. +// +// Skipping the repeat is not cosmetic: without it, running the same read command five times +// to watch a value change fills the history with five identical entries and makes up useless. +func (p *Prompt) pushHistory(line string) { + p.cursor = len(p.history) + if line == "" { + return + } + if len(p.history) > 0 && p.history[len(p.history)-1] == line { + p.cursor = len(p.history) + return + } + p.history = append(p.history, line) + p.trimHistory() + p.cursor = len(p.history) +} + +// trimHistory drops the oldest lines once the limit is exceeded. +func (p *Prompt) trimHistory() { + if len(p.history) <= p.historyLimit { + return + } + drop := len(p.history) - p.historyLimit + p.history = append([]string(nil), p.history[drop:]...) + p.cursor = min(p.cursor, len(p.history)) +} + +// historyBack walks towards the oldest line. It stops at the oldest rather than wrapping, +// because wrapping in a history means a long hold silently lands somewhere unexpected. +func (p *Prompt) historyBack() { + if len(p.history) == 0 { + return + } + if p.cursor == len(p.history) { + // Leaving the live line: remember it so that walking forward again gives it back. + p.draft = p.input.Value() + } + if p.cursor == 0 { + return + } + p.cursor-- + p.recall(p.history[p.cursor]) +} + +// historyForward walks towards the newest line, and one step past it back to the line that was +// in progress. +func (p *Prompt) historyForward() { + if p.cursor >= len(p.history) { + return + } + p.cursor++ + if p.cursor == len(p.history) { + p.recall(p.draft) + return + } + p.recall(p.history[p.cursor]) +} + +// recall puts a remembered line in the field. +func (p *Prompt) recall(line string) { + p.input.SetValue(line) + p.input.CursorEnd() + // Closed, not open: a recalled line is complete, and leaving the list open would hand the + // next up press to the completion list and strand the user mid-history. + p.syncCompletion(false) +} + +// syncCompletion refreshes the candidates for the current line and sets whether the list is +// showing. It is the only place p.completionOpen is assigned to true. +func (p *Prompt) syncCompletion(open bool) { + if p.suggest == nil { + p.input.ShowSuggestions = false + p.input.SetSuggestions(nil) + p.completionOpen = false + return + } + candidates := p.suggest(p.input.Value()) + // ShowSuggestions gates textinput's own matching, including its up and down bindings, so + // turning it off with no candidates keeps those keys out of the way of the history. + p.input.ShowSuggestions = len(candidates) > 0 + p.input.SetSuggestions(candidates) + p.completionOpen = open && len(p.input.MatchedSuggestions()) > 0 +} + +// fieldWidth is the width left for the text field once the label and the in-flight indication +// have taken theirs. +func (p Prompt) fieldWidth() int { + return p.width - lipgloss.Width(p.Label) - lipgloss.Width(p.statusText()) - 1 +} + +// statusText is the unstyled in-flight indication: spinner frame, elapsed time and the hint +// that esc aborts. Empty when nothing is running. +func (p Prompt) statusText() string { + if !p.busy { + return "" + } + frames := p.theme.Glyphs.Spinner + frame := "" + if len(frames) > 0 { + frame = frames[p.frame%len(frames)] + } + return frame + " " + formatElapsed(p.Elapsed()) + " esc cancel" +} + +// View renders the prompt as exactly one line of exactly the configured width. +// +// One line, always: the responsive rules reserve a row for it in every layout, and the bug +// this replaces was a prompt that was allowed to vanish. The completion list is rendered +// separately by CompletionView so that the caller can place it as an overlay without the +// prompt row growing. +func (p Prompt) View() string { + if p.width <= 0 { + return "" + } + + // The marker is drawn in the accent rather than muted: it is the one place on the screen + // that takes typing, so it should read as active chrome and not as a label. + label := p.theme.OnSurface(p.theme.Accent.Bold(true)).Render(p.Label) + status := "" + if text := p.statusText(); text != "" { + status = p.theme.OnSurface(p.theme.Accent).Render(" " + text) + } + + // The field gets whatever is left. fitLine then forces the assembled row to the exact + // width, so a long line scrolls inside the field instead of pushing the status off the + // end of the terminal. + fieldRoom := max(p.width-lipgloss.Width(label)-lipgloss.Width(status), 1) + field := p.padOnSurface(p.input.View(), fieldRoom) + + return p.padOnSurface(label+field+status, p.width) +} + +// padOnSurface is fitLine with the band's background on the padding, so the raised row runs +// the full width instead of stopping where the text does. +func (p Prompt) padOnSurface(line string, width int) string { + if actual := lipgloss.Width(line); actual < width { + return line + p.theme.Surface.Render(strings.Repeat(" ", width-actual)) + } + return fitLine(line, width) +} + +// CompletionView renders the completion list, at most maxRows entries, or the empty string +// when the list is closed. The caller places it; on a short terminal it is an overlay over +// the pane above the prompt rather than a region competing for rows. +func (p Prompt) CompletionView(maxRows int) string { + if !p.completionOpen || maxRows < 1 { + return "" + } + candidates := p.input.MatchedSuggestions() + if len(candidates) == 0 { + return "" + } + + selected := p.input.CurrentSuggestionIndex() + // Scroll the window so the highlighted candidate is always in it. + start := 0 + if selected >= maxRows { + start = selected - maxRows + 1 + } + end := min(start+maxRows, len(candidates)) + + indent := strings.Repeat(" ", lipgloss.Width(p.Label)) + inner := max(p.width-lipgloss.Width(indent), 1) + + var out strings.Builder + for i := start; i < end; i++ { + if i > start { + out.WriteString("\n") + } + marker := p.theme.Glyphs.Unselected + style := p.theme.Muted + if i == selected { + marker = p.theme.Glyphs.Selected + style = p.theme.SelectedRow + } + row := fitLine(marker+" "+candidates[i], inner) + out.WriteString(indent) + out.WriteString(style.Render(row)) + } + // More candidates than rows: say so rather than letting the list look complete. + if end < len(candidates) { + out.WriteString("\n") + out.WriteString(indent) + out.WriteString(p.theme.Muted.Render(fitLine(p.theme.Glyphs.Ellipsis+" "+strconv.Itoa(len(candidates)-end)+" more", inner))) + } + return out.String() +} + +// formatElapsed renders a duration compactly enough for the prompt row: tenths below a +// minute, minutes and seconds above it. +func formatElapsed(d time.Duration) string { + if d < 0 { + d = 0 + } + if d < time.Minute { + tenths := int64(d / (100 * time.Millisecond)) + return strconv.FormatInt(tenths/10, 10) + "." + strconv.FormatInt(tenths%10, 10) + "s" + } + minutes := int64(d / time.Minute) + seconds := int64(d % time.Minute / time.Second) + out := strconv.FormatInt(minutes, 10) + "m" + if seconds < 10 { + out += "0" + } + return out + strconv.FormatInt(seconds, 10) + "s" +} diff --git a/plc4go/tools/internal/tui/prompt_test.go b/plc4go/tools/internal/tui/prompt_test.go new file mode 100644 index 000000000..84b9249a6 --- /dev/null +++ b/plc4go/tools/internal/tui/prompt_test.go @@ -0,0 +1,486 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package tui + +import ( + "strconv" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// testCommands is the command set the completion tests draw on. They share a "re" prefix on +// purpose, so that a partial line has several candidates and the ordering of the completion +// list is observable. +var testCommands = []string{"read", "reconnect", "reset", "restart", "write"} + +// passthroughSuggest is what a real SuggestFunc looks like at its simplest: it offers the +// whole command set and lets textinput do the prefix matching, which is the contract +// documented on SuggestFunc. +func passthroughSuggest(commands []string) SuggestFunc { + return func(string) []string { return commands } +} + +// fakeClock is the injected clock. The elapsed indication is asserted on, so it must not come +// from the wall clock. +type fakeClock struct { + at time.Time +} + +func (c *fakeClock) Now() time.Time { return c.at } + +func (c *fakeClock) advance(d time.Duration) { c.at = c.at.Add(d) } + +// newTestPrompt builds a focused prompt of a known width with the ASCII glyph set and no +// colour, so that assertions on rendered text are stable. +func newTestPrompt(t *testing.T, suggest SuggestFunc) Prompt { + t.Helper() + prompt := NewPrompt(NewTheme(Options{NoColor: true, ASCII: true}), suggest) + prompt.SetWidth(80) + prompt.Focus() + require.True(t, prompt.Focused()) + return prompt +} + +// typeText feeds a line in one character at a time, as a terminal would. +func typeText(prompt Prompt, text string) Prompt { + for _, r := range text { + prompt, _ = prompt.Update(charKey(r)) + } + return prompt +} + +// press sends one key and discards the command. +func press(prompt Prompt, msg tea.KeyPressMsg) Prompt { + prompt, _ = prompt.Update(msg) + return prompt +} + +func TestTypingAtThePromptEntersTextRatherThanFiringShortcuts(t *testing.T) { + prompt := newTestPrompt(t, nil) + + prompt = typeText(prompt, "read holding-register:1") + + assert.Equal(t, "read holding-register:1", prompt.Value()) +} + +// TestUpAndDownWalkTheCompletionListWhileItIsOpen covers the first half of the rule that +// resolves the collision with textinput's own up and down bindings. +func TestUpAndDownWalkTheCompletionListWhileItIsOpen(t *testing.T) { + prompt := newTestPrompt(t, passthroughSuggest(testCommands)) + + prompt = typeText(prompt, "re") + + require.True(t, prompt.CompletionOpen()) + require.Equal(t, []string{"read", "reconnect", "reset", "restart"}, prompt.Completions()) + require.Equal(t, 0, prompt.CompletionIndex()) + + prompt = press(prompt, namedKey(tea.KeyDown)) + assert.Equal(t, 1, prompt.CompletionIndex(), "down must move through the completion list") + + prompt = press(prompt, namedKey(tea.KeyDown)) + assert.Equal(t, 2, prompt.CompletionIndex()) + + prompt = press(prompt, namedKey(tea.KeyUp)) + assert.Equal(t, 1, prompt.CompletionIndex(), "up must move back through the completion list") + + // Walking the list does not commit anything to the line, and it does not touch the + // history: the line the user typed is still the line. + assert.Equal(t, "re", prompt.Value()) + assert.Empty(t, prompt.History()) +} + +// TestUpAndDownWalkTheHistoryWhileTheCompletionListIsClosed covers the second half of the +// same rule. +func TestUpAndDownWalkTheHistoryWhileTheCompletionListIsClosed(t *testing.T) { + prompt := newTestPrompt(t, passthroughSuggest(testCommands)) + + prompt = typeText(prompt, "read a") + prompt = press(prompt, namedKey(tea.KeyEnter)) + prompt = typeText(prompt, "write b 1") + prompt = press(prompt, namedKey(tea.KeyEnter)) + + require.Equal(t, []string{"read a", "write b 1"}, prompt.History()) + require.False(t, prompt.CompletionOpen(), "running a line closes the completion list") + + prompt = press(prompt, namedKey(tea.KeyUp)) + assert.Equal(t, "write b 1", prompt.Value(), "up must recall the newest line first") + + prompt = press(prompt, namedKey(tea.KeyUp)) + assert.Equal(t, "read a", prompt.Value()) + + prompt = press(prompt, namedKey(tea.KeyUp)) + assert.Equal(t, "read a", prompt.Value(), "the walk must stop at the oldest line, not wrap") + + prompt = press(prompt, namedKey(tea.KeyDown)) + assert.Equal(t, "write b 1", prompt.Value()) + + prompt = press(prompt, namedKey(tea.KeyDown)) + assert.Equal(t, "", prompt.Value(), "walking forward out of the history returns the live line") + + prompt = press(prompt, namedKey(tea.KeyDown)) + assert.Equal(t, "", prompt.Value(), "the walk must stop at the live line, not wrap") +} + +func TestHistoryWalkDoesNotLoseTheLineInProgress(t *testing.T) { + prompt := newTestPrompt(t, passthroughSuggest(testCommands)) + + prompt = typeText(prompt, "read a") + prompt = press(prompt, namedKey(tea.KeyEnter)) + + // "part" matches nothing in the command set, so the completion list is closed and up is + // unambiguously history. + prompt = typeText(prompt, "part") + require.False(t, prompt.CompletionOpen()) + + prompt = press(prompt, namedKey(tea.KeyUp)) + require.Equal(t, "read a", prompt.Value()) + + prompt = press(prompt, namedKey(tea.KeyDown)) + assert.Equal(t, "part", prompt.Value(), "the half-typed line must come back") +} + +func TestARecalledLineKeepsUpOnHistoryRatherThanHandingItToCompletion(t *testing.T) { + // A recalled line is usually a whole command, so it matches candidates. If the recall + // re-opened the completion list, the next up press would jump to the candidate list and + // strand the user half way through the history. + prompt := newTestPrompt(t, passthroughSuggest(testCommands)) + + for _, line := range []string{"read", "reset"} { + prompt = typeText(prompt, line) + prompt = press(prompt, namedKey(tea.KeyEnter)) + } + + prompt = press(prompt, namedKey(tea.KeyUp)) + require.Equal(t, "reset", prompt.Value()) + require.False(t, prompt.CompletionOpen(), "a recall must not open the completion list") + + prompt = press(prompt, namedKey(tea.KeyUp)) + assert.Equal(t, "read", prompt.Value(), "up must still be walking the history") +} + +func TestEscapeClosesTheCompletionListAndThenBelongsToTheModel(t *testing.T) { + prompt := newTestPrompt(t, passthroughSuggest(testCommands)) + + prompt = typeText(prompt, "re") + require.True(t, prompt.CompletionOpen()) + require.True(t, prompt.ConsumesEscape(), "the first esc is the prompt's, to close the list") + + prompt = press(prompt, namedKey(tea.KeyEsc)) + assert.False(t, prompt.CompletionOpen()) + assert.Equal(t, "re", prompt.Value(), "closing the list must not clear the line") + assert.False(t, prompt.ConsumesEscape(), "the next esc belongs to the model's dismiss chain") + + // With the list closed, up is history again rather than completion. + prompt = typeText(prompt, "ad") + prompt = press(prompt, namedKey(tea.KeyEnter)) + prompt = press(prompt, namedKey(tea.KeyUp)) + assert.Equal(t, "read", prompt.Value()) +} + +func TestCtrlNAndCtrlPAlwaysMeanTheCompletionList(t *testing.T) { + // The pair that never changes meaning, so there is always a way to reach the list even + // with up and down committed to the history. + prompt := newTestPrompt(t, passthroughSuggest(testCommands)) + + prompt = typeText(prompt, "re") + prompt = press(prompt, namedKey(tea.KeyEsc)) + require.False(t, prompt.CompletionOpen()) + + prompt = press(prompt, ctrlKey('n')) + require.True(t, prompt.CompletionOpen(), "ctrl+n must reopen the list") + assert.Equal(t, 0, prompt.CompletionIndex(), "the first press only makes the list visible") + + prompt = press(prompt, ctrlKey('n')) + assert.Equal(t, 1, prompt.CompletionIndex()) + + prompt = press(prompt, ctrlKey('p')) + assert.Equal(t, 0, prompt.CompletionIndex()) +} + +func TestTabAcceptsTheHighlightedCandidateAndClosesTheList(t *testing.T) { + prompt := newTestPrompt(t, passthroughSuggest(testCommands)) + + prompt = typeText(prompt, "re") + prompt = press(prompt, namedKey(tea.KeyDown)) + require.Equal(t, 1, prompt.CompletionIndex()) + + prompt = press(prompt, namedKey(tea.KeyTab)) + + assert.Equal(t, "reconnect", prompt.Value()) + assert.False(t, prompt.CompletionOpen(), "accepting a candidate closes the list") +} + +func TestSubmitEmitsTheLineClearsTheFieldAndRemembersIt(t *testing.T) { + prompt := newTestPrompt(t, passthroughSuggest(testCommands)) + + prompt = typeText(prompt, " read holding-register:1 ") + prompt, cmd := prompt.Update(namedKey(tea.KeyEnter)) + + require.NotNil(t, cmd) + submitted, ok := cmd().(PromptSubmitMsg) + require.True(t, ok, "running a line must emit a PromptSubmitMsg") + assert.Equal(t, "read holding-register:1", submitted.Line, "the line is trimmed before it is run") + + assert.Equal(t, "", prompt.Value()) + assert.Equal(t, []string{"read holding-register:1"}, prompt.History()) +} + +func TestSubmittingAnEmptyLineRunsNothingAndRemembersNothing(t *testing.T) { + prompt := newTestPrompt(t, passthroughSuggest(testCommands)) + + prompt, cmd := prompt.Update(namedKey(tea.KeyEnter)) + + assert.Nil(t, cmd) + assert.Empty(t, prompt.History()) +} + +func TestHistoryIsBoundedAndDropsTheOldestLines(t *testing.T) { + prompt := newTestPrompt(t, nil) + prompt.SetHistoryLimit(3) + + for i := range 5 { + prompt = typeText(prompt, "read tag"+strconv.Itoa(i)) + prompt = press(prompt, namedKey(tea.KeyEnter)) + } + + assert.Equal(t, []string{"read tag2", "read tag3", "read tag4"}, prompt.History()) + + // The walk still reaches the oldest surviving line and stops there. + for range 5 { + prompt = press(prompt, namedKey(tea.KeyUp)) + } + assert.Equal(t, "read tag2", prompt.Value()) +} + +func TestARepeatedLineIsRememberedOnce(t *testing.T) { + // Running the same read five times to watch a value change would otherwise fill the + // history with five identical entries and make up useless. + prompt := newTestPrompt(t, nil) + + for range 3 { + prompt = typeText(prompt, "read a") + prompt = press(prompt, namedKey(tea.KeyEnter)) + } + prompt = typeText(prompt, "read b") + prompt = press(prompt, namedKey(tea.KeyEnter)) + + assert.Equal(t, []string{"read a", "read b"}, prompt.History()) +} + +func TestHistoryReturnedToCallersCannotBeUsedToEditThePrompt(t *testing.T) { + prompt := newTestPrompt(t, nil) + prompt = typeText(prompt, "read a") + prompt = press(prompt, namedKey(tea.KeyEnter)) + + stolen := prompt.History() + stolen[0] = "rm -rf" + + assert.Equal(t, []string{"read a"}, prompt.History()) +} + +func TestPromptWithoutASuggestFuncNeverOpensCompletionAndKeepsUpOnHistory(t *testing.T) { + prompt := newTestPrompt(t, nil) + + prompt = typeText(prompt, "read a") + prompt = press(prompt, namedKey(tea.KeyEnter)) + assert.False(t, prompt.CompletionOpen()) + assert.Empty(t, prompt.Completions()) + + prompt = press(prompt, namedKey(tea.KeyDown)) + assert.Equal(t, "", prompt.Value(), "down at the live line is a no-op") + + prompt = press(prompt, namedKey(tea.KeyUp)) + assert.Equal(t, "read a", prompt.Value()) +} + +// TestPromptRendersExactlyOneLineOfTheGivenWidth pins the invariant the responsive rules are +// built around: the prompt is never dropped and never clipped, whatever the terminal size. +// The UI this replaces hardcoded a thirty-column field, which clipped any real connection +// string as it was typed. +func TestPromptRendersExactlyOneLineOfTheGivenWidth(t *testing.T) { + longLine := "read c-bus://192.168.178.101?srchk=false holding-register:1234:UINT[8]" + + for _, width := range []int{MinWidth, 72, 80, 90, 120, 200} { + t.Run("w"+strconv.Itoa(width), func(t *testing.T) { + for _, line := range []string{"", "read", longLine} { + prompt := newTestPrompt(t, passthroughSuggest(testCommands)) + prompt.SetWidth(width) + prompt = typeText(prompt, line) + + view := prompt.View() + + assert.NotContains(t, view, "\n", "the prompt is one row in every layout") + assert.Equal(t, width, lipgloss.Width(view), "the prompt fills its width exactly") + assert.Contains(t, view, "$", "the label must survive") + } + }) + } +} + +// deterministicLine builds a line of exactly n cells whose every position is identifiable, so +// that a clipped field cannot accidentally satisfy an assertion about the whole line. +func deterministicLine(n int) string { + const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789" + var out strings.Builder + for i := range n { + out.WriteByte(alphabet[i%len(alphabet)]) + } + return out.String() +} + +// TestThePromptFieldSpansTheAvailableWidth is the regression test for the other half of the +// prompt defect: the UI this replaces called SetFieldWidth(30), so a real connection string +// was clipped as it was typed and the user could not see what they had entered. +func TestThePromptFieldSpansTheAvailableWidth(t *testing.T) { + for _, width := range []int{60, 80, 120} { + t.Run("w"+strconv.Itoa(width), func(t *testing.T) { + // Three cells go to the "$ " label and the cursor; everything else is the field. + line := deterministicLine(width - 3) + + prompt := newTestPrompt(t, nil) + prompt.SetWidth(width) + prompt = typeText(prompt, line) + + view := prompt.View() + + require.Equal(t, line, prompt.Value()) + assert.Contains(t, view, line, "the whole line must be visible, not a fixed-width window onto it") + assert.Equal(t, width, lipgloss.Width(view)) + + // Past that, the field scrolls rather than growing, so the end the user is typing + // at stays visible and the row keeps its width. + prompt = typeText(prompt, "TAIL") + scrolled := prompt.View() + assert.Contains(t, scrolled, "TAIL") + assert.Equal(t, width, lipgloss.Width(scrolled)) + }) + } +} + +func TestPromptShowsTheSpinnerAndElapsedTimeWhileACommandIsInFlight(t *testing.T) { + clock := &fakeClock{at: time.Unix(1700000000, 0).UTC()} + prompt := newTestPrompt(t, nil) + prompt.SetClock(clock.Now) + + assert.NotContains(t, prompt.View(), "esc cancel", "an idle prompt advertises no abort") + + prompt.Start() + require.True(t, prompt.Busy()) + clock.advance(1500 * time.Millisecond) + + prompt, cmd := prompt.Update(PromptTickMsg{Time: clock.at}) + require.NotNil(t, cmd, "a busy prompt must keep the spinner turning") + + frames := ASCIIGlyphs().Spinner + view := prompt.View() + assert.Contains(t, view, frames[1], "one tick advances one frame") + assert.Contains(t, view, "1.5s") + assert.Contains(t, view, "esc cancel") + assert.Equal(t, 80, lipgloss.Width(view), "the in-flight indication must not widen the row") + + prompt.Finish() + clock.advance(time.Hour) + assert.Zero(t, prompt.Elapsed(), "a finished command has no elapsed time") + assert.NotContains(t, prompt.View(), "esc cancel") + + // An idle prompt stops re-arming the tick, so an idle tool does not spin forever. + _, idleCmd := prompt.Update(PromptTickMsg{Time: clock.at}) + assert.Nil(t, idleCmd) +} + +func TestElapsedIsFormattedForTheOneRowTheresRoomFor(t *testing.T) { + for _, testCase := range []struct { + elapsed time.Duration + want string + }{ + {0, "0.0s"}, + {99 * time.Millisecond, "0.0s"}, + {1500 * time.Millisecond, "1.5s"}, + {59900 * time.Millisecond, "59.9s"}, + {time.Minute, "1m00s"}, + {91 * time.Second, "1m31s"}, + {125 * time.Minute, "125m00s"}, + } { + t.Run(testCase.want, func(t *testing.T) { + assert.Equal(t, testCase.want, formatElapsed(testCase.elapsed)) + }) + } +} + +func TestCompletionViewMarksTheHighlightedCandidateAndSaysWhatItHid(t *testing.T) { + prompt := newTestPrompt(t, passthroughSuggest(testCommands)) + prompt = typeText(prompt, "re") + require.Len(t, prompt.Completions(), 4) + + glyphs := ASCIIGlyphs() + + full := prompt.CompletionView(4) + lines := strings.Split(full, "\n") + require.Len(t, lines, 4) + assert.Contains(t, lines[0], glyphs.Selected+" read") + assert.NotContains(t, lines[1], glyphs.Selected) + + // A window shorter than the list has to say so, or a truncated list looks complete. + windowed := prompt.CompletionView(2) + windowedLines := strings.Split(windowed, "\n") + require.Len(t, windowedLines, 3) + assert.Contains(t, windowedLines[2], "2 more") + + for _, line := range append(lines, windowedLines...) { + assert.LessOrEqual(t, lipgloss.Width(line), prompt.Width(), "the completion list must not overflow") + } + + // The window follows the highlight, so a selection past the bottom stays visible. + prompt = press(prompt, namedKey(tea.KeyDown)) + prompt = press(prompt, namedKey(tea.KeyDown)) + require.Equal(t, 2, prompt.CompletionIndex()) + scrolled := strings.Split(prompt.CompletionView(2), "\n") + assert.Contains(t, scrolled[1], glyphs.Selected+" reset") + + assert.Empty(t, prompt.CompletionView(0)) +} + +func TestClosedCompletionListRendersNothing(t *testing.T) { + prompt := newTestPrompt(t, passthroughSuggest(testCommands)) + prompt = typeText(prompt, "re") + prompt = press(prompt, namedKey(tea.KeyEsc)) + + assert.Empty(t, prompt.CompletionView(5)) + assert.Empty(t, prompt.Completions()) +} + +func TestABlurredPromptIgnoresKeysAndDropsTheCompletionList(t *testing.T) { + prompt := newTestPrompt(t, passthroughSuggest(testCommands)) + prompt = typeText(prompt, "re") + require.True(t, prompt.CompletionOpen()) + + prompt.Blur() + assert.False(t, prompt.CompletionOpen(), "a list under an unfocused field lies about where input goes") + + prompt = typeText(prompt, "ad") + assert.Equal(t, "re", prompt.Value(), "a blurred prompt must not swallow the pane's keys") + assert.False(t, prompt.ConsumesEscape()) +} diff --git a/plc4go/tools/internal/tui/theme.go b/plc4go/tools/internal/tui/theme.go new file mode 100644 index 000000000..471713490 --- /dev/null +++ b/plc4go/tools/internal/tui/theme.go @@ -0,0 +1,384 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +// Package tui holds the presentation vocabulary shared by the plc4go terminal tools: the +// colour theme, the glyph sets and the pane chrome. +// +// It exists so that styling decisions live in exactly one place. The tools it serves +// previously wrote colours as raw markup literals inline, such as "[#0000ff]" and "[white]", +// which meant the palette could not adapt to the terminal, could not be switched off, and +// was pure blue on a dark background in places where that is barely legible. +package tui + +import ( + "image/color" + "os" + "strings" + + "charm.land/lipgloss/v2" +) + +// Options selects a theme variant. +type Options struct { + // Dark says whether the terminal background is dark. Under Bubble Tea this comes from + // tea.BackgroundColorMsg, which arrives during startup and again whenever it changes. + Dark bool + // NoColor strips all colour, for NO_COLOR, a dumb terminal or a golden-file test. + NoColor bool + // ASCII selects the ASCII glyph set instead of the Unicode one. + ASCII bool +} + +// OptionsFromEnv derives options from the environment, honouring the NO_COLOR convention and +// the locale-based Unicode guess. An explicit --ascii flag should override the result. +func OptionsFromEnv(dark bool) Options { + _, noColorSet := os.LookupEnv("NO_COLOR") + return Options{ + Dark: dark, + NoColor: noColorSet, + ASCII: !SupportsUnicodeFromEnv(), + } +} + +// Theme is the resolved set of styles and glyphs a view renders with. +type Theme struct { + // Glyphs is the drawing-character set. + Glyphs Glyphs + + // Title is the application name in the status bar. + Title lipgloss.Style + // Badge marks a mode that must be impossible to miss, such as demo mode. + Badge lipgloss.Style + // Muted is for secondary text: counts, hints, units. + Muted lipgloss.Style + // Key is for the key part of a key/value pair, and for keybinding names. + Key lipgloss.Style + // Value is for the value part of a key/value pair. + Value lipgloss.Style + // Accent draws attention without implying a problem. + Accent lipgloss.Style + // Ok, Warn and Err carry status meaning. + Ok lipgloss.Style + Warn lipgloss.Style + Err lipgloss.Style + + // Pane is the chrome around an unfocused pane, PaneFocused around the focused one. The + // difference has to be visible at a glance: not knowing which pane has the keyboard was + // one of the worst parts of the previous UIs. + Pane lipgloss.Style + PaneFocused lipgloss.Style + // Chrome and ChromeFocused colour border glyphs that are drawn by hand, as Pane does when + // it embeds a title in the top edge. These carry a foreground colour ONLY: a style with a + // Border set would draw a fresh box around every character it rendered. + Chrome lipgloss.Style + ChromeFocused lipgloss.Style + + // PaneTitle and PaneTitleFocused style the name carried in the pane's top border. + PaneTitle lipgloss.Style + PaneTitleFocused lipgloss.Style + + // SelectedRow highlights the current row of a list or table. + SelectedRow lipgloss.Style + + // Surface is the raised background of the command bar, and OnSurface puts other styles + // onto it. A terminal cannot nest a background: an inner style's reset ends the outer + // background mid-row, so a banded row has to be assembled from styles that each carry + // the background themselves rather than wrapped in one that has it. + Surface lipgloss.Style + + // The data-type families. A tag's type is looked up with DataType rather than picked here, + // so a call site never has to know which family a type name belongs to. + Numeric lipgloss.Style + Boolean lipgloss.Style + Text lipgloss.Style + Temporal lipgloss.Style + + // dark records which variant produced this theme, for tests and for re-resolving. + dark bool + // noColor records whether colour was stripped. + noColor bool + // surface is the band's background colour, kept so OnSurface can graft it onto any style. + surface color.Color + // accent is the accent colour itself, for the few places that need a colour and not a style. + accent color.Color +} + +// IsDark reports which background variant this theme was built for. +func (t Theme) IsDark() bool { return t.dark } + +// IsNoColor reports whether the theme was built without colour. +func (t Theme) IsNoColor() bool { return t.noColor } + +// The palette. Each entry is a light/dark pair, chosen so that contrast holds on both. These +// are the only colour literals in the tools. +var ( + paletteAccent = pair{light: "#7D56F4", dark: "#A78BFA"} + paletteOk = pair{light: "#03713B", dark: "#4ADE80"} + paletteWarn = pair{light: "#8A5A00", dark: "#FBBF24"} + paletteErr = pair{light: "#B3261E", dark: "#F87171"} + paletteMuted = pair{light: "#5C5C66", dark: "#9CA3AF"} + paletteText = pair{light: "#1F2933", dark: "#E5E7EB"} + paletteChrome = pair{light: "#9AA0AA", dark: "#4B5563"} + // Raised just enough to read as a distinct band against either background, and no more: + // the command bar should look like a surface, not like a selection. + paletteSurface = pair{light: "#E4E4E9", dark: "#26262F"} + + // Data-type hues. A tag list is read by shape as much as by name -- "which of these are + // numbers" is a faster question to answer by colour than by reading each type -- so the + // families get their own hues rather than all sharing the muted grey. + paletteNumeric = pair{light: "#0E7490", dark: "#22D3EE"} + paletteBoolean = pair{light: "#B45309", dark: "#F59E0B"} + paletteTypeText = pair{light: "#3F6212", dark: "#A3E635"} + paletteTemporal = pair{light: "#6D28D9", dark: "#C4B5FD"} +) + +// pair is a light/dark colour pair. +type pair struct { + light string + dark string +} + +// NewTheme resolves a theme for the given options. +func NewTheme(options Options) Theme { + // lightDark picks the member of a pair that suits the background. When colour is off, + // every style is left unset instead, which renders as the terminal's own default. + lightDark := lipgloss.LightDark(options.Dark) + pick := func(p pair) color.Color { + return lightDark(lipgloss.Color(p.light), lipgloss.Color(p.dark)) + } + + // foreground builds a style, or a bare style when colour is disabled. + foreground := func(p pair) lipgloss.Style { + if options.NoColor { + return lipgloss.NewStyle() + } + return lipgloss.NewStyle().Foreground(pick(p)) + } + + glyphs := GlyphsFor(!options.ASCII) + + theme := Theme{ + Glyphs: glyphs, + dark: options.Dark, + noColor: options.NoColor, + + Title: foreground(paletteText).Bold(true), + Muted: foreground(paletteMuted), + Key: foreground(paletteMuted), + Value: foreground(paletteText), + Accent: foreground(paletteAccent), + Ok: foreground(paletteOk), + Warn: foreground(paletteWarn), + Err: foreground(paletteErr), + } + + // The badge has to read as a badge even with colour off, so it keeps its brackets there. + if options.NoColor { + theme.Badge = lipgloss.NewStyle().Bold(true) + } else { + theme.Badge = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Background(pick(paletteAccent)). + Bold(true). + Padding(0, 1) + } + + theme.Pane = lipgloss.NewStyle().Border(glyphs.Border) + theme.PaneFocused = lipgloss.NewStyle().Border(glyphs.Border) + if !options.NoColor { + theme.Pane = theme.Pane.BorderForeground(pick(paletteChrome)) + theme.PaneFocused = theme.PaneFocused.BorderForeground(pick(paletteAccent)) + } + + theme.Numeric = foreground(paletteNumeric) + theme.Boolean = foreground(paletteBoolean) + theme.Text = foreground(paletteTypeText) + theme.Temporal = foreground(paletteTemporal) + + theme.Chrome = foreground(paletteChrome) + theme.ChromeFocused = foreground(paletteAccent) + + theme.PaneTitle = foreground(paletteMuted) + theme.PaneTitleFocused = foreground(paletteAccent).Bold(true) + + // Without colour, the selected row is marked by the Selected glyph alone, which the views + // draw regardless; reverse video would fight the glyph and hurt readability. + if options.NoColor { + theme.SelectedRow = lipgloss.NewStyle().Bold(true) + } else { + theme.SelectedRow = lipgloss.NewStyle().Foreground(pick(paletteAccent)).Bold(true) + } + + // With colour off there is no band, so Surface is bare and OnSurface is the identity. + if !options.NoColor { + theme.accent = pick(paletteAccent) + theme.surface = pick(paletteSurface) + theme.Surface = lipgloss.NewStyle().Background(theme.surface) + } + + return theme +} + +// CursorColor is the colour of the text cursor, or nil when colour is off. It is the accent, +// because the cursor marks the one place on the screen that takes typing. +func (t Theme) CursorColor() color.Color { + if t.noColor { + return nil + } + return t.accent +} + +// OnSurface returns style as it should be drawn on the command bar: the same foreground, with +// the band's background. It is the identity when colour is off. +func (t Theme) OnSurface(style lipgloss.Style) lipgloss.Style { + if t.noColor || t.surface == nil { + return style + } + return style.Background(t.surface) +} + +// Surfaced returns the theme as it should be drawn on a band: every foreground-only style +// carries the band's background, so a whole row can be assembled without any segment punching +// a hole in it. Badge is left alone, because it sets a background of its own and reads as a +// badge precisely by contrasting with whatever it sits on. +func (t Theme) Surfaced() Theme { + if t.noColor || t.surface == nil { + return t + } + on := func(style lipgloss.Style) lipgloss.Style { return style.Background(t.surface) } + surfaced := t + surfaced.Title = on(t.Title) + surfaced.Muted = on(t.Muted) + surfaced.Key = on(t.Key) + surfaced.Value = on(t.Value) + surfaced.Accent = on(t.Accent) + surfaced.Ok = on(t.Ok) + surfaced.Warn = on(t.Warn) + surfaced.Err = on(t.Err) + surfaced.Chrome = on(t.Chrome) + surfaced.Numeric = on(t.Numeric) + surfaced.Boolean = on(t.Boolean) + surfaced.Text = on(t.Text) + surfaced.Temporal = on(t.Temporal) + return surfaced +} + +// DataType returns the style for a PLC data type, by family. +// +// The families are what a reader actually distinguishes: numbers, flags, text and times. An +// unknown type falls back to Muted rather than to an arbitrary hue, so a type this does not +// know about is visibly uncategorised instead of miscategorised. +func (t Theme) DataType(dataType string) lipgloss.Style { + switch normaliseDataType(dataType) { + case dataNumeric: + return t.Numeric + case dataBoolean: + return t.Boolean + case dataText: + return t.Text + case dataTemporal: + return t.Temporal + default: + return t.Muted + } +} + +// dataFamily groups the PLC data types by how they are read. +type dataFamily int + +const ( + dataUnknown dataFamily = iota + dataNumeric + dataBoolean + dataText + dataTemporal +) + +// normaliseDataType maps a plc4x type name onto its family. +// +// The names come from IEC 61131-3, so they are a closed and well-known set; matching on the +// suffix rather than an exhaustive list keeps the sized variants (LREAL, ULINT, WSTRING) in the +// right family without listing every one. +func normaliseDataType(dataType string) dataFamily { + name := strings.ToUpper(strings.TrimSpace(dataType)) + switch { + case name == "": + return dataUnknown + case name == "BOOL" || name == "BIT": + return dataBoolean + case strings.HasSuffix(name, "STRING") || name == "CHAR" || name == "WCHAR": + return dataText + // IEC 61131-3 spells every long temporal type as the base name with an L in front -- + // LTIME, LDATE, LTOD, LDT, LDATE_AND_TIME -- so the L is dropped before matching rather + // than each spelling being listed. That is safe here only because this branch runs before + // the numeric one: LINT, LREAL and LWORD all reduce to names no temporal prefix matches. + case isTemporalName(strings.TrimPrefix(name, "L")): + return dataTemporal + case strings.HasSuffix(name, "REAL") || strings.HasSuffix(name, "INT") || + strings.HasSuffix(name, "WORD") || name == "BYTE": + return dataNumeric + default: + return dataUnknown + } +} + +// isTemporalName reports whether an L-stripped type name is a time or a date. +func isTemporalName(name string) bool { + return strings.HasPrefix(name, "TIME") || strings.HasPrefix(name, "DATE") || + strings.HasPrefix(name, "TOD") || name == "DT" +} + +// LogLevel returns the style for a log level, so a wall of log lines has shape. +func (t Theme) LogLevel(level string) lipgloss.Style { + switch strings.ToLower(strings.TrimSpace(level)) { + case "error", "err", "fatal", "panic": + return t.Err + case "warn", "warning": + return t.Warn + case "info": + return t.Accent + default: + // trace and debug are the noise you turned the level up to see; they stay quiet. + return t.Muted + } +} + +// PaneStyle returns the chrome for a pane, focused or not. +func (t Theme) PaneStyle(focused bool) lipgloss.Style { + if focused { + return t.PaneFocused + } + return t.Pane +} + +// ChromeStyle returns the border-glyph colour for a pane, focused or not. +func (t Theme) ChromeStyle(focused bool) lipgloss.Style { + if focused { + return t.ChromeFocused + } + return t.Chrome +} + +// PaneTitleStyle returns the title style for a pane, focused or not. +func (t Theme) PaneTitleStyle(focused bool) lipgloss.Style { + if focused { + return t.PaneTitleFocused + } + return t.PaneTitle +} diff --git a/plc4go/tools/internal/tui/theme_test.go b/plc4go/tools/internal/tui/theme_test.go new file mode 100644 index 000000000..7297e8557 --- /dev/null +++ b/plc4go/tools/internal/tui/theme_test.go @@ -0,0 +1,188 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package tui + +import ( + "regexp" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestDataTypeGroupsByFamily pins the mapping from IEC 61131-3 type names onto the four +// families a reader actually distinguishes. The names are matched by suffix, so the sized and +// unsized spellings of one type have to land together. +func TestDataTypeGroupsByFamily(t *testing.T) { + theme := NewTheme(Options{Dark: true}) + + families := map[string][]string{ + "numeric": {"INT", "UINT", "SINT", "USINT", "DINT", "UDINT", "LINT", "ULINT", "REAL", "LREAL", "BYTE", "WORD", "DWORD", "LWORD"}, + "boolean": {"BOOL", "BIT"}, + "text": {"STRING", "WSTRING", "CHAR", "WCHAR"}, + "temporal": {"TIME", "LTIME", "DATE", "LDATE", "TIME_OF_DAY", "TOD", "LTOD", "DATE_AND_TIME", "LDATE_AND_TIME", "DT", "LDT"}, + } + want := map[string]string{ + "numeric": theme.Numeric.Render("x"), + "boolean": theme.Boolean.Render("x"), + "text": theme.Text.Render("x"), + "temporal": theme.Temporal.Render("x"), + } + + // The four families have to be visually distinct, or grouping by them buys nothing. + seen := map[string]string{} + for family, rendered := range want { + if other, clash := seen[rendered]; clash { + t.Fatalf("families %q and %q render identically", family, other) + } + seen[rendered] = family + } + + for family, types := range families { + for _, dataType := range types { + assert.Equal(t, want[family], theme.DataType(dataType).Render("x"), + "%s should be %s", dataType, family) + // Case is not part of the contract: a driver may report either. + assert.Equal(t, want[family], theme.DataType(strings.ToLower(dataType)).Render("x"), + "%s should be %s in lower case too", dataType, family) + } + } +} + +// TestDataTypeLeavesUnknownTypesUncategorised is the deliberate choice not to guess: a type +// this does not know about must read as uncategorised rather than as a wrong family. +func TestDataTypeLeavesUnknownTypesUncategorised(t *testing.T) { + theme := NewTheme(Options{Dark: true}) + for _, dataType := range []string{"", "STRUCT", "ARRAY", "RAW_BYTES", "unknown"} { + assert.Equal(t, theme.Muted.Render("x"), theme.DataType(dataType).Render("x"), dataType) + } +} + +// TestLogLevelDistinguishesTheLevelsThatMatter checks that a warning and an error are each +// visually separable from an ordinary line -- the only reason to colour a log at all. +func TestLogLevelDistinguishesTheLevelsThatMatter(t *testing.T) { + theme := NewTheme(Options{Dark: true}) + + info := theme.LogLevel("info").Render("x") + warn := theme.LogLevel("warn").Render("x") + err := theme.LogLevel("error").Render("x") + + assert.NotEqual(t, info, warn) + assert.NotEqual(t, info, err) + assert.NotEqual(t, warn, err) + + // trace and debug are the noise you turned the level up to see: they are quieter than an + // info line, not louder, and an unknown level is treated as noise rather than as an alarm. + quiet := theme.Muted.Render("x") + for _, level := range []string{"", "trace", "debug", "nonsense"} { + assert.Equal(t, quiet, theme.LogLevel(level).Render("x"), level) + assert.NotEqual(t, warn, theme.LogLevel(level).Render("x"), level) + assert.NotEqual(t, err, theme.LogLevel(level).Render("x"), level) + } + assert.NotEqual(t, quiet, info, "an info line should read as more than noise") + + // Spelling and case vary between loggers; the aliases must land on the same style. + assert.Equal(t, err, theme.LogLevel("ERROR").Render("x")) + assert.Equal(t, err, theme.LogLevel("fatal").Render("x")) + assert.Equal(t, err, theme.LogLevel(" panic ").Render("x")) + assert.Equal(t, warn, theme.LogLevel("warning").Render("x")) + assert.Equal(t, warn, theme.LogLevel("WARN").Render("x")) +} + +// TestNoColorThemeHasNoBandAtAll matters because the band is a background, and a background is +// exactly what a NO_COLOR terminal must not receive: it would print as solid blocks. +func TestNoColorThemeHasNoBandAtAll(t *testing.T) { + theme := NewTheme(Options{Dark: true, NoColor: true}) + + assert.Equal(t, " ", theme.Surface.Render(" "), "Surface must be inert without colour") + assert.NotContains(t, theme.Surface.Render(" "), "\x1b") + + // OnSurface and Surfaced are the identity, so a call site needs no NoColor branch. + assert.Equal(t, theme.Accent.Render("x"), theme.OnSurface(theme.Accent).Render("x")) + assert.Equal(t, theme.Muted.Render("x"), theme.Surfaced().Muted.Render("x")) + for _, rendered := range []string{ + theme.Surfaced().Title.Render("x"), + theme.Surfaced().Err.Render("x"), + theme.Surfaced().Numeric.Render("x"), + } { + assert.NotContains(t, rendered, "\x1b[4", "no background may reach a NO_COLOR terminal") + } +} + +// TestSurfacedKeepsForegroundAndAddsBackground is the property the banded rows rely on: the +// meaning of a style is its foreground, and putting it on the band must not change that. +func TestSurfacedKeepsForegroundAndAddsBackground(t *testing.T) { + for _, dark := range []bool{true, false} { + theme := NewTheme(Options{Dark: dark}) + surfaced := theme.Surfaced() + band := theme.Surface.Render(" ") + require.Contains(t, band, "\x1b[4", "the band must set a background") + background := backgroundOf(t, band) + + for name, pair := range map[string][2]string{ + "Title": {theme.Title.Render("x"), surfaced.Title.Render("x")}, + "Muted": {theme.Muted.Render("x"), surfaced.Muted.Render("x")}, + "Accent": {theme.Accent.Render("x"), surfaced.Accent.Render("x")}, + "Err": {theme.Err.Render("x"), surfaced.Err.Render("x")}, + "Numeric": {theme.Numeric.Render("x"), surfaced.Numeric.Render("x")}, + "Temporal": {theme.Temporal.Render("x"), surfaced.Temporal.Render("x")}, + } { + plain, onBand := pair[0], pair[1] + assert.NotEqual(t, plain, onBand, "%s should differ on the band", name) + assert.Contains(t, onBand, background, "%s should carry the band background", name) + for _, code := range foregroundsOf(plain) { + assert.Contains(t, onBand, code, "%s should keep its foreground", name) + } + } + + // The badge sets a background of its own and reads by contrasting with what it sits + // on, so Surfaced must leave it alone. + assert.Equal(t, theme.Badge.Render("DEMO"), surfaced.Badge.Render("DEMO")) + } +} + +// trueColour matches one truecolour SGR parameter run, foreground (38) or background (48). +// lipgloss merges a style's parameters into a single sequence, so the run and not the whole +// sequence is what one style's contribution to another's rendering looks like. +var trueColour = regexp.MustCompile(`(38|48);2;[0-9]+;[0-9]+;[0-9]+`) + +// backgroundOf extracts the background parameter run from a rendered string. +func backgroundOf(t *testing.T, rendered string) string { + t.Helper() + for _, run := range trueColour.FindAllString(rendered, -1) { + if strings.HasPrefix(run, "48;") { + return run + } + } + t.Fatalf("no background in %q", rendered) + return "" +} + +// foregroundsOf extracts the foreground parameter runs from a rendered string. +func foregroundsOf(rendered string) []string { + var codes []string + for _, run := range trueColour.FindAllString(rendered, -1) { + if strings.HasPrefix(run, "38;") { + codes = append(codes, run) + } + } + return codes +} diff --git a/plc4go/tools/internal/tui/tui_test.go b/plc4go/tools/internal/tui/tui_test.go new file mode 100644 index 000000000..b094e8d0e --- /dev/null +++ b/plc4go/tools/internal/tui/tui_test.go @@ -0,0 +1,219 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package tui + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestGlyphSetsHaveMatchingWidths is the property the ASCII fallback depends on. Layout +// arithmetic is done in display cells, so if an ASCII glyph were a different width from the +// Unicode glyph it replaces, switching sets would shift or clip every pane that uses it. +func TestGlyphSetsHaveMatchingWidths(t *testing.T) { + unicode, ascii := UnicodeGlyphs(), ASCIIGlyphs() + + pairs := map[string][2]string{ + "Selected": {unicode.Selected, ascii.Selected}, + "Unselected": {unicode.Unselected, ascii.Unselected}, + "Yes": {unicode.Yes, ascii.Yes}, + "No": {unicode.No, ascii.No}, + "Inbound": {unicode.Inbound, ascii.Inbound}, + "Outbound": {unicode.Outbound, ascii.Outbound}, + "Ok": {unicode.Ok, ascii.Ok}, + "Warn": {unicode.Warn, ascii.Warn}, + "Err": {unicode.Err, ascii.Err}, + "Ellipsis": {unicode.Ellipsis, ascii.Ellipsis}, + "Separator": {unicode.Separator, ascii.Separator}, + "ProgressFull": {unicode.ProgressFull, ascii.ProgressFull}, + "ProgressEmpty": {unicode.ProgressEmpty, ascii.ProgressEmpty}, + } + for name, p := range pairs { + t.Run(name, func(t *testing.T) { + assert.Equal(t, lipgloss.Width(p[0]), lipgloss.Width(p[1]), + "%s: unicode %q is %d cells, ascii %q is %d cells", + name, p[0], lipgloss.Width(p[0]), p[1], lipgloss.Width(p[1])) + }) + } +} + +// TestEveryGlyphIsExactlyOneCell keeps the sets usable in fixed-width columns. +func TestEveryGlyphIsExactlyOneCell(t *testing.T) { + for setName, set := range map[string]Glyphs{"unicode": UnicodeGlyphs(), "ascii": ASCIIGlyphs()} { + for name, glyph := range map[string]string{ + "Selected": set.Selected, "Unselected": set.Unselected, + "Yes": set.Yes, "No": set.No, + "Inbound": set.Inbound, "Outbound": set.Outbound, + "Ok": set.Ok, "Warn": set.Warn, "Err": set.Err, + "Ellipsis": set.Ellipsis, "Separator": set.Separator, + "ProgressFull": set.ProgressFull, "ProgressEmpty": set.ProgressEmpty, + } { + t.Run(setName+"/"+name, func(t *testing.T) { + assert.Equal(t, 1, lipgloss.Width(glyph), "%q must occupy one cell", glyph) + }) + } + } +} + +func TestSpinnerFramesAreUniformWidth(t *testing.T) { + for setName, set := range map[string]Glyphs{"unicode": UnicodeGlyphs(), "ascii": ASCIIGlyphs()} { + t.Run(setName, func(t *testing.T) { + require.NotEmpty(t, set.Spinner) + for _, frame := range set.Spinner { + assert.Equal(t, 1, lipgloss.Width(frame), + "spinner frame %q must be one cell or the prompt will jitter", frame) + } + }) + } +} + +func TestASCIIGlyphsAreActuallyASCII(t *testing.T) { + set := ASCIIGlyphs() + all := strings.Join(append([]string{ + set.Selected, set.Unselected, set.Yes, set.No, set.Inbound, set.Outbound, + set.Ok, set.Warn, set.Err, set.Ellipsis, set.Separator, + set.ProgressFull, set.ProgressEmpty, + }, set.Spinner...), "") + for _, r := range all { + assert.Less(t, r, rune(128), "the ascii set must contain no multi-byte rune, found %q", r) + } +} + +// TestASCIIBorderIsASCII guards the pane chrome, which is the most visible Unicode in the UI. +func TestASCIIBorderIsASCII(t *testing.T) { + border := ASCIIGlyphs().Border + for _, part := range []string{ + border.Top, border.Bottom, border.Left, border.Right, + border.TopLeft, border.TopRight, border.BottomLeft, border.BottomRight, + } { + for _, r := range part { + assert.Less(t, r, rune(128), "ascii border part %q contains a multi-byte rune %q", part, r) + } + } +} + +func TestSupportsUnicodeReadsTheLocale(t *testing.T) { + tests := map[string]struct { + env map[string]string + want bool + }{ + "utf-8 in LANG": {map[string]string{"LANG": "en_US.UTF-8"}, true}, + "utf8 without hyphen": {map[string]string{"LANG": "en_US.utf8"}, true}, + "LC_ALL wins over LANG": {map[string]string{"LC_ALL": "C", "LANG": "en_US.UTF-8"}, false}, + "LC_CTYPE utf-8": {map[string]string{"LC_CTYPE": "de_DE.UTF-8"}, true}, + "plain C locale": {map[string]string{"LANG": "C"}, false}, + "POSIX locale": {map[string]string{"LANG": "POSIX"}, false}, + "nothing set at all": {map[string]string{}, false}, + "empty vars fall through": {map[string]string{"LC_ALL": "", "LANG": "en_GB.UTF-8"}, true}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := SupportsUnicode(func(key string) string { return tc.env[key] }) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestGlyphsForSelectsTheSet(t *testing.T) { + assert.Equal(t, UnicodeGlyphs().Selected, GlyphsFor(true).Selected) + assert.Equal(t, ASCIIGlyphs().Selected, GlyphsFor(false).Selected) +} + +// TestThemeRendersDifferentlyForLightAndDark proves the palette actually adapts rather than +// being nominally adaptive. The old UIs hardcoded colours such as pure blue, which is close +// to illegible on a dark background. +func TestThemeRendersDifferentlyForLightAndDark(t *testing.T) { + light := NewTheme(Options{Dark: false}) + dark := NewTheme(Options{Dark: true}) + + assert.NotEqual(t, light.Accent.Render("x"), dark.Accent.Render("x"), + "the accent colour must differ between light and dark backgrounds") + assert.NotEqual(t, light.Err.Render("x"), dark.Err.Render("x")) + assert.False(t, light.IsDark()) + assert.True(t, dark.IsDark()) +} + +// TestNoColorThemeEmitsNoEscapeSequences is the NO_COLOR contract, and it is also what makes +// golden-file tests stable. +func TestNoColorThemeEmitsNoEscapeSequences(t *testing.T) { + theme := NewTheme(Options{Dark: true, NoColor: true}) + require.True(t, theme.IsNoColor()) + + for name, style := range map[string]lipgloss.Style{ + "Title": theme.Title, "Muted": theme.Muted, "Key": theme.Key, "Value": theme.Value, + "Accent": theme.Accent, "Ok": theme.Ok, "Warn": theme.Warn, "Err": theme.Err, + "SelectedRow": theme.SelectedRow, "Badge": theme.Badge, + "PaneTitle": theme.PaneTitle, "PaneTitleFocused": theme.PaneTitleFocused, + } { + t.Run(name, func(t *testing.T) { + rendered := style.Render("sample") + assert.NotContains(t, rendered, "\x1b[3", "%s must not set a foreground colour", name) + assert.NotContains(t, rendered, "\x1b[4", "%s must not set a background colour", name) + assert.Contains(t, rendered, "sample", "%s must still render its text", name) + }) + } +} + +func TestNoColorPanesKeepTheirBorders(t *testing.T) { + theme := NewTheme(Options{Dark: true, NoColor: true}) + // Losing colour must not lose structure: the borders are what explain the layout. + rendered := theme.Pane.Render("content") + assert.Contains(t, rendered, "content") + assert.Greater(t, strings.Count(rendered, "\n"), 1, "a bordered pane spans several lines") +} + +// TestFocusIsVisuallyDistinct pins the fix for having had no focus indication at all. +func TestFocusIsVisuallyDistinct(t *testing.T) { + theme := NewTheme(Options{Dark: true}) + assert.NotEqual(t, + theme.PaneStyle(false).Render("x"), + theme.PaneStyle(true).Render("x"), + "the focused pane must be distinguishable from an unfocused one") + assert.NotEqual(t, + theme.PaneTitleStyle(false).Render("x"), + theme.PaneTitleStyle(true).Render("x")) +} + +func TestASCIIOptionSelectsTheASCIIBorder(t *testing.T) { + assert.Equal(t, ASCIIGlyphs().Border, NewTheme(Options{ASCII: true}).Glyphs.Border) + assert.Equal(t, UnicodeGlyphs().Border, NewTheme(Options{ASCII: false}).Glyphs.Border) +} + +func TestOptionsFromEnvHonoursNoColor(t *testing.T) { + t.Setenv("NO_COLOR", "1") + assert.True(t, OptionsFromEnv(true).NoColor) + + // An empty NO_COLOR still counts as set, per the convention: presence is the signal. + t.Setenv("NO_COLOR", "") + assert.True(t, OptionsFromEnv(true).NoColor) +} + +func TestOptionsFromEnvDerivesASCIIFromLocale(t *testing.T) { + t.Setenv("LC_ALL", "en_US.UTF-8") + t.Setenv("LANG", "en_US.UTF-8") + assert.False(t, OptionsFromEnv(true).ASCII, "a UTF-8 locale should keep the Unicode glyphs") + + t.Setenv("LC_ALL", "C") + assert.True(t, OptionsFromEnv(true).ASCII, "a C locale should fall back to ASCII") +} diff --git a/plc4go/tools/internal/tuitest/painted.go b/plc4go/tools/internal/tuitest/painted.go new file mode 100644 index 000000000..0b0260883 --- /dev/null +++ b/plc4go/tools/internal/tuitest/painted.go @@ -0,0 +1,128 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +// Package tuitest holds assertions about rendered output that both tools need. +// +// It exists rather than each tool keeping a copy because the logic here is fiddly enough to +// drift: two hand-written SGR parsers that disagree would give two different answers to the +// same question about the same screen. +package tuitest + +import ( + "regexp" + "strconv" + "strings" + + "charm.land/lipgloss/v2" +) + +// Escapes matches one SGR sequence, for tests that need to look at or strip the styling. +var Escapes = regexp.MustCompile("\x1b\\[[0-9;]*m") + +// Strip removes all styling from a rendered string. +func Strip(rendered string) string { return Escapes.ReplaceAllString(rendered, "") } + +// PaintedCells counts the leading cells of a row that the terminal actually fills in. +// +// A background set once covers every cell after it until a reset or an explicit +// default-background takes it away; a foreground or an attribute leaves it alone. That is why +// this walks cells rather than styled segments: the bug it was written to catch is a bare space +// *between* two styled pieces, which no per-segment check would see. +// +// Reverse video counts as painted. bubbles draws the text cursor that way and its background is +// not settable, so it is the one cell of a command bar carrying no background of its own -- and +// it still paints a solid block, which is all this asks about. +func PaintedCells(line string) int { + cells, painted := 0, false + rest := line + for rest != "" { + location := Escapes.FindStringIndex(rest) + if location != nil && location[0] == 0 { + for param := range strings.SplitSeq(strings.Trim(rest[2:location[1]], "m"), ";") { + switch param { + case "", "0", "49": + painted = false + case "7", "48", "40", "41", "42", "43", "44", "45", "46", "47", + "100", "101", "102", "103", "104", "105", "106", "107": + painted = true + } + } + rest = rest[location[1]:] + continue + } + + next := len(rest) + if location != nil { + next = location[0] + } + if !painted { + return cells + } + cells += lipgloss.Width(rest[:next]) + rest = rest[next:] + } + return cells +} + +// ColourCodes returns every SGR sequence in a rendered string that sets a colour, so a test +// can assert that a NO_COLOR render sets none. Attributes and resets are not colours: NO_COLOR +// asks for no colour, and bold and reverse video are how a title and a cursor still read +// without one. +// +// The whole sequence is returned rather than the parameter that gave it away, because a +// failure message naming "\x1b[38;2;167;139;250m" says what leaked and one naming "38" does not. +func ColourCodes(rendered string) []string { + var colours []string + for _, code := range Escapes.FindAllString(rendered, -1) { + for param := range strings.SplitSeq(strings.Trim(code[2:], "m"), ";") { + if !setsColour(param) { + continue + } + colours = append(colours, code) + // One report per sequence. 38 and 48 are followed by their arguments, which are + // numbers rather than parameters and would otherwise be read as colours of their + // own -- an RGB green of 31 is not the SGR red 31. + break + } + } + return colours +} + +// setsColour reports whether one SGR parameter selects a colour, as opposed to setting an +// attribute or resetting one. +func setsColour(param string) bool { + switch param { + case "38", "48", "58": + // Extended colour: 38;2;R;G;B or 38;5;N. + return true + } + number, err := strconv.Atoi(param) + if err != nil { + return false + } + switch { + case number >= 30 && number <= 37, number >= 40 && number <= 47, + number >= 90 && number <= 97, number >= 100 && number <= 107: + return true + default: + // Everything else is an attribute (bold, reverse) or a reset, including 39 and 49, + // which put the terminal's own colours back. + return false + } +} diff --git a/plc4go/tools/plc4xbrowser/browser/appearance_test.go b/plc4go/tools/plc4xbrowser/browser/appearance_test.go new file mode 100644 index 000000000..87ea25a45 --- /dev/null +++ b/plc4go/tools/plc4xbrowser/browser/appearance_test.go @@ -0,0 +1,282 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package browser + +import ( + "strconv" + "strings" + "testing" + "time" + + "charm.land/lipgloss/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/plcsession" + "github.com/apache/plc4x-extras/plc4go/tools/internal/tui" + "github.com/apache/plc4x-extras/plc4go/tools/internal/tuitest" +) + +// The rest of the suite renders with the plain theme, because assertions read better against +// text than against escape sequences. These tests are the exception: they exist because the +// coloured themes are what a user actually sees, and three of the mistakes made building this +// screen -- a band that stopped a cell short of the edge, a style whose reset punched a hole +// in the band, and a background reaching a NO_COLOR terminal -- are invisible in plain text. + +// TestBandedRowsCoverTheFullWidth pins the fix for a command bar whose background stopped one +// cell short of the right edge, which reads as a notch in the bar rather than as a bar. +func TestBandedRowsCoverTheFullWidth(t *testing.T) { + for _, dark := range []bool{true, false} { + theme := tui.NewTheme(tui.Options{Dark: dark}) + m := newTestModelWithTheme(t, theme) + + // Several widths: the status bar drops pieces as it narrows, and each candidate has to + // pad back out to the full width. + for _, width := range []int{157, 120, 100, 80, 64} { + m = sized(t, m, width, 40) + rows := strings.Split(m.render(), "\n") + require.Len(t, rows, 40) + + for _, row := range []struct { + name string + index int + }{ + {"status bar", 0}, + {"command bar", len(rows) - 2}, + } { + line := rows[row.index] + assert.Equal(t, width, lipgloss.Width(line), + "%s at %d columns should be exactly the screen width", row.name, width) + assert.Equal(t, width, tuitest.PaintedCells(line), + "%s at %d columns should be banded across its whole width", row.name, width) + } + } + } +} + +// TestNoColorRenderIsFreeOfColour is the NO_COLOR contract for the whole screen rather than for +// one style. Bold and reverse video stay: NO_COLOR asks for no colour, and those are how a +// title and a cursor still read without one. A background is the one thing that cannot degrade +// gracefully, because it prints as a solid block. +func TestNoColorRenderIsFreeOfColour(t *testing.T) { + m := newTestModelWithTheme(t, tui.NewTheme(tui.Options{Dark: true, NoColor: true})) + m = sized(t, m, 120, 40) + m.appendLogAt("error", "something failed") + m.appendLogAt("warn", "something is odd") + render := m.render() + + assert.Empty(t, tuitest.ColourCodes(render), "a NO_COLOR render must set no colour") + // Whatever else it does, it must not paint a background anywhere. + assert.Equal(t, 0, tuitest.PaintedCells(strings.Split(render, "\n")[0]), + "a NO_COLOR status bar must not be banded") +} + +// TestColouredRenderKeepsEveryRowExactlyTheScreenWidth guards the whole screen, not just the +// bands: a row that miscounts its cells because of an escape sequence tears every row below it. +func TestColouredRenderKeepsEveryRowExactlyTheScreenWidth(t *testing.T) { + m := newTestModelWithTheme(t, tui.NewTheme(tui.Options{Dark: true})) + for _, size := range [][2]int{{157, 50}, {100, 30}, {80, 24}, {64, 14}} { + m = sized(t, m, size[0], size[1]) + rows := strings.Split(m.render(), "\n") + assert.Len(t, rows, size[1]) + for i, row := range rows { + assert.Equal(t, size[0], lipgloss.Width(row), "row %d at %dx%d", i, size[0], size[1]) + } + } +} + +// TestTagValuesAreColouredByType checks that the four type families reach the screen: the +// point of typing the values was that a number, a flag, a string and a time read differently. +func TestTagValuesAreColouredByType(t *testing.T) { + theme := tui.NewTheme(tui.Options{Dark: true}) + + rendered := map[string]string{} + for _, tag := range []plcsession.TagResult{ + {Name: "a", DataType: "REAL", Value: "21.5"}, + {Name: "b", DataType: "BOOL", Value: "true"}, + {Name: "c", DataType: "STRING", Value: "running"}, + {Name: "d", DataType: "TIME", Value: "1h2m"}, + } { + rendered[tag.DataType] = renderTagValue(theme, tag) + } + + seen := map[string]string{} + for dataType, value := range rendered { + codes := tuitest.Escapes.FindAllString(value, -1) + require.NotEmpty(t, codes, "%s should be styled", dataType) + if other, clash := seen[codes[0]]; clash { + t.Fatalf("%s and %s render in the same colour", dataType, other) + } + seen[codes[0]] = dataType + assert.Contains(t, tuitest.Strip(value), rendered[dataType][:0]+value[:0]) + } +} + +// TestRenderTagValueSplitsAccessFlags covers the browse case: the value carries the access +// triple and then the tag's human name, and the two read better apart. +func TestRenderTagValueSplitsAccessFlags(t *testing.T) { + theme := tui.NewTheme(tui.Options{Dark: true, NoColor: true}) + + for name, test := range map[string]struct { + tag plcsession.TagResult + want string + }{ + "flags and name": {plcsession.TagResult{Value: "rw- Temperature 1"}, "rw- Temperature 1"}, + "flags alone": {plcsession.TagResult{Value: "rws"}, "rws"}, + "all denied": {plcsession.TagResult{Value: "--- Hidden"}, "--- Hidden"}, + "plain value": {plcsession.TagResult{DataType: "REAL", Value: "21.5"}, "21.5"}, + "three-char word": {plcsession.TagResult{DataType: "STRING", Value: "off now"}, "off now"}, + } { + assert.Equal(t, test.want, renderTagValue(theme, test.tag), name) + } +} + +// TestIsAccessFlagsRejectsLookalikes is why isAccessFlags exists at all: a three-character +// value that merely looks like a triple must not be styled as one. +func TestIsAccessFlagsRejectsLookalikes(t *testing.T) { + for _, flags := range []string{"rws", "rw-", "r--", "---", "-w-", "--s"} { + assert.True(t, isAccessFlags(flags), flags) + } + for _, other := range []string{"", "rw", "rwsx", "off", "abc", "RWS", "r w", "srw", "wrs"} { + assert.False(t, isAccessFlags(other), other) + } +} + +// TestRoundDurationDropsNoiseAndKeepsSignal pins the precision rule. A demo read reporting +// "62.25Âĩs" is noise; a real one taking 1.2 seconds must not be rounded to "1s". +func TestRoundDurationDropsNoiseAndKeepsSignal(t *testing.T) { + for _, test := range []struct { + in time.Duration + want string + }{ + {62250 * time.Nanosecond, "62Âĩs"}, + {999 * time.Nanosecond, "1Âĩs"}, + {4*time.Millisecond + 213*time.Microsecond, "4.2ms"}, + {999 * time.Millisecond, "999ms"}, + {1234 * time.Millisecond, "1.23s"}, + {2*time.Second + 4*time.Millisecond, "2s"}, + {90 * time.Second, "1m30s"}, + } { + assert.Equal(t, test.want, roundDuration(test.in), test.in.String()) + } + + // Whatever the precision, a duration never renders as nothing. + for _, d := range []time.Duration{0, time.Nanosecond, time.Hour} { + assert.NotEmpty(t, roundDuration(d)) + } +} + +// TestLogLevelTagsAreAlignedAndDistinct matters because the tags are what gives a wall of log +// lines shape: unequal widths would ragged the left edge of every message. +func TestLogLevelTagsAreAlignedAndDistinct(t *testing.T) { + tags := map[string]string{} + for _, level := range []string{"error", "warn", "info", "debug", "trace", "", "nonsense"} { + tag := logLevelTag(level) + assert.Len(t, tag, 3, "%q should be a three-letter tag", level) + tags[level] = tag + } + + assert.Equal(t, "ERR", tags["error"]) + assert.Equal(t, "WRN", tags["warn"]) + assert.Equal(t, "INF", tags["info"]) + // Anything else is an ordinary line rather than a fourth marker. + for _, level := range []string{"debug", "trace", "", "nonsense"} { + assert.Equal(t, "INF", tags[level], level) + } +} + +// TestLogLinesCarryTheirLevel checks the level survives into the rendered console, which is +// what lets a failure be spotted in the log without reading it. +func TestLogLinesCarryTheirLevel(t *testing.T) { + m := newTestModelWithTheme(t, tui.NewTheme(tui.Options{Dark: true})) + m = sized(t, m, 120, 40) + + m.appendLogAt("info", "connected") + m.appendLogAt("error", "read failed") + + lines := m.LogLines() + require.GreaterOrEqual(t, len(lines), 2) + info, failure := lines[len(lines)-2], lines[len(lines)-1] + + assert.Contains(t, tuitest.Strip(info), "INF") + assert.Contains(t, tuitest.Strip(failure), "ERR") + + // The two lines have to be told apart by colour and not only by their three letters. The + // comparison is over the whole set of codes in each line rather than the first, because + // both lines open with the same muted timestamp; it is the level marker that differs. + theme := tui.NewTheme(tui.Options{Dark: true}) + errCode := tuitest.Escapes.FindAllString(theme.Err.Render("x"), -1)[0] + assert.Contains(t, tuitest.Escapes.FindAllString(failure, -1), errCode, "an error line should be red") + assert.NotContains(t, tuitest.Escapes.FindAllString(info, -1), errCode, "an info line should not") +} + +// TestSelectedMessageRowIsVisuallyDistinct guards a regression that no plain-theme test can +// see. bubbles/table styles each cell and then styles the selected row around them, so a +// foreground on the cell style ends with a reset that wipes the selection from every cell +// after the first -- and the selected row becomes indistinguishable from its neighbours. +func TestSelectedMessageRowIsVisuallyDistinct(t *testing.T) { + theme := tui.NewTheme(tui.Options{Dark: true}) + m := newTestModelWithTheme(t, theme) + m = sized(t, m, 120, 30) + for _, address := range []string{"temp/1", "temp/2", "press/1"} { + _, cmd := m.Update(tui.PromptSubmitMsg{Line: "read-direct " + plcsession.DemoDeviceOne + " " + address}) + runCmd(t, m, cmd) + } + _, total := m.EventCount() + require.Equal(t, 3, total) + + rows := messageRows(t, m) + require.Len(t, rows, 3) + + // Follow keeps the newest selected, so the last row is the one carrying the selection. + selected, plain := rows[2], rows[0] + selectionCode := tuitest.Escapes.FindString(theme.SelectedRow.Render("x")) + require.NotEmpty(t, selectionCode) + + assert.Contains(t, selected, selectionCode, "the selected row should carry the selection style") + assert.NotContains(t, plain, selectionCode, "an unselected row should not") + + // And the selection has to survive past the first cell, which is the actual bug: the row + // is styled once, and each cell's own styling would end it at the first reset. The run the + // selection opens therefore has to still be open when the time column arrives. + run := selected[strings.Index(selected, selectionCode)+len(selectionCode):] + if end := tuitest.Escapes.FindStringIndex(run); end != nil { + run = run[:end[0]] + } + assert.Contains(t, run, "12:", + "the selection should still be in force at the time column, not ended after the first cell") +} + +// messageRows returns the body rows of the message table, stripped of the pane chrome around +// them but with their styling intact. +func messageRows(t *testing.T, m *Model) []string { + t.Helper() + var rows []string + for line := range strings.SplitSeq(m.render(), "\n") { + plain := tuitest.Strip(line) + for index := 1; index <= 3; index++ { + marker := "\u2502" + strconv.Itoa(index) + " " + if strings.Contains(plain, marker) { + rows = append(rows, line) + } + } + } + return rows +} diff --git a/plc4go/tools/plc4xbrowser/browser/command.go b/plc4go/tools/plc4xbrowser/browser/command.go new file mode 100644 index 000000000..fe08dbe4d --- /dev/null +++ b/plc4go/tools/plc4xbrowser/browser/command.go @@ -0,0 +1,371 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package browser + +import ( + "context" + "slices" + "strings" + "time" + + "github.com/apache/plc4x/plc4go/spi/errors" + "github.com/rs/zerolog" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/plcsession" +) + +// The command layer. +// +// Nothing here knows that a terminal exists. A command is data: a name, a description, an +// optional argument summary, subcommands, a completion function and a Run function that takes +// a context and an [Env] and returns a [Result]. The previous implementation instead had each +// action write into package-level tview widgets, which is why not one of its commands could be +// tested and why four of them were never finished at all. +// +// Result is a value rather than a set of callbacks for the same reason: the model applies it, +// so a test can run every command against a plcsession.Demo and assert on what came back +// without constructing a user interface. + +// Operation is the kind of request the composer is opened for. +type Operation string + +// The operations the request composer can be opened for. These are the names of the four +// commands that the previous implementation advertised but answered with "mode switch not yet +// implemented". +const ( + OperationRead Operation = "read" + OperationWrite Operation = "write" + OperationBrowse Operation = "browse" + OperationSubscribe Operation = "subscribe" +) + +// ClearTarget is a set of outputs to empty, as a bit set so that the bare `clear` command can +// name all of them at once. +type ClearTarget uint8 + +// The clearable outputs. Messages is the message table and its detail; Console is the log +// pane, which is where both the log and command output land. +const ( + ClearMessages ClearTarget = 1 << iota + ClearConsole + ClearCommands +) + +// ClearAll is every output, which is what the bare `clear` command asks for. +const ClearAll = ClearMessages | ClearConsole | ClearCommands + +// Has reports whether t includes target. +func (t ClearTarget) Has(target ClearTarget) bool { return t&target != 0 } + +// ComposeSpec asks the model to open the request composer, pre-set for one operation. +type ComposeSpec struct { + Operation Operation + // Connection is the connection to pre-select, empty when the user named none. + Connection string + // Tags pre-fills the tag rows; a nil slice means start with one empty row. + Tags []plcsession.TagSpec + // Query pre-fills the browse query. + Query string +} + +// Stream is a live subscription handed back to the model to drain. +// +// The channel is drained by a tea.Cmd that reads one event and re-arms, never by a goroutine +// that touches model state. Cancel stops the subscription; the model calls it when the +// connection is closed, when the user aborts, and at shutdown. +type Stream struct { + Connection string + // Label names the subscription in the UI, typically the tag address. + Label string + Events <-chan plcsession.Event + Cancel context.CancelFunc +} + +// Result is everything a command asks the user interface to do. The zero Result means "the +// command succeeded and there is nothing to show", which is a legitimate outcome. +type Result struct { + // Lines are console lines to append to the log pane. + Lines []string + // Events are entries for the message table. + Events []plcsession.Event + // Compose, when set, opens the request composer. + Compose *ComposeSpec + // Stream, when set, is a subscription for the model to drain. + Stream *Stream + // Quit asks the application to exit. + Quit bool + // Clear names outputs to empty. + Clear ClearTarget + // LogLevel, when set, is the new log level. + LogLevel *zerolog.Level + // ConnectionsChanged and DriversChanged tell the sidebar to re-read the session. + ConnectionsChanged bool + DriversChanged bool +} + +// Env is what a command is allowed to reach. It is deliberately small: a session, the +// persisted settings, a clock and a way to obtain a context that outlives the command. +type Env struct { + // Session is the PLC seam. Never nil. + Session plcsession.Session + // Config is the persisted settings, which the connect history and the auto-register list + // live in. Never nil. + Config *Config + // Now supplies timestamps, injected so that tests can pin them. + Now func() time.Time + // StreamContext returns a context that outlives the command that created it, for a + // subscription. A command's own context is cancelled when the command returns, so a + // subscription started on it would die immediately. + StreamContext func() (context.Context, context.CancelFunc) + // Registry is the command tree, so that `help` can enumerate it without a global. + Registry *Registry + // Debug mirrors the old plc4xbrowser-debug switch: the browser's own debug logging. + Debug bool + // Demo records whether the session is the simulated one, so that suggestions and error + // messages can name something the user can actually reach. + Demo bool +} + +// Command is one node of the command tree. +type Command struct { + // Name is the word that selects this command. + Name string + // Description is the one-line help text. + Description string + // Args summarises the arguments, for the help listing. + Args string + // Sub are the subcommands. + Sub []*Command + // Run executes the command. A nil Run means the command only groups subcommands. + Run func(ctx context.Context, env *Env, args string) (Result, error) + // Suggest returns completion candidates for the argument text typed so far. The candidates + // are argument text, not whole lines: the registry prepends the command path. + Suggest func(env *Env, partial string) []string +} + +// Registry is a resolved command tree. +type Registry struct { + root *Command +} + +// NewRegistry builds a registry over a root command whose Sub are the top-level commands. +func NewRegistry(root *Command) *Registry { return &Registry{root: root} } + +// Root exposes the root command, for the help listing and for tests. +func (r *Registry) Root() *Command { return r.root } + +// Names lists the top-level command names in tree order. +func (r *Registry) Names() []string { + names := make([]string, 0, len(r.root.Sub)) + for _, command := range r.root.Sub { + names = append(names, command.Name) + } + return names +} + +// Resolve finds the deepest command the line names, and returns it with the path that was +// consumed and the argument text that was not. +// +// A word is only consumed as a command name when it is followed by a space. That is what keeps +// `read` and `read-direct` apart: on the line "read-direct x" the word "read" is not a +// candidate, because the line does not start with "read ". The previous implementation needed +// a special case for exactly this (acceptsCurrentText refused a match when the text continued +// with a hyphen), which broke for any command name that was a prefix of another with a +// different separator. +func (r *Registry) Resolve(line string) (command *Command, path string, args string) { + command = r.root + rest := strings.TrimLeft(line, " ") + var consumed []string + + for { + child, remainder, ok := matchChild(command, rest) + if !ok { + break + } + consumed = append(consumed, child.Name) + command = child + rest = remainder + } + // The argument text keeps its trailing whitespace, because that is the only thing that + // distinguishes a word still being typed from a word that is finished. On + // "read-direct demo://plant-1" the connection is still being typed and the connections are + // what to suggest; on "read-direct demo://plant-1 " it is settled and the tags are. Trimming + // here made those two lines identical, so the tag list only appeared once a letter of a tag + // had been typed. Execute trims the line before resolving it, so nothing reaches a command + // with a trailing space. + return command, strings.Join(consumed, " "), strings.TrimLeft(rest, " \t") +} + +// matchChild consumes a leading child-command name from rest. +// +// Only a complete word counts, and only one that is followed by more text: "log" on its own is +// still being typed, so it is left for the completion of the word rather than treated as a +// resolved command with no arguments. Execute puts the word back before dispatching. +func matchChild(parent *Command, rest string) (*Command, string, bool) { + for _, child := range parent.Sub { + if after, found := strings.CutPrefix(rest, child.Name+" "); found { + return child, strings.TrimLeft(after, " "), true + } + } + return nil, "", false +} + +// Execute runs the command a line names. +// +// The line is resolved first, so an unknown word is reported as an unknown command rather than +// being handed to some parent as an argument. +func (r *Registry) Execute(ctx context.Context, env *Env, line string) (Result, error) { + line = strings.TrimSpace(line) + if line == "" { + return Result{}, nil + } + + command, path, args := r.Resolve(line) + // Resolve leaves a trailing bare word unconsumed so that completion can still work on it. + // Executing has no such need, so a remaining word that names a child is taken now. + if child := exactChild(command, args); child != nil { + command, args = child, "" + path = strings.TrimSpace(path + " " + child.Name) + } + + if command == r.root { + return Result{}, errors.Errorf("unknown command %q, try help", firstWord(line)) + } + if command.Run == nil { + return Result{}, errors.Errorf("%s needs one of: %s", path, strings.Join(childNames(command), ", ")) + } + if args != "" && len(command.Sub) > 0 && exactChild(command, firstWord(args)) == nil && command.Suggest == nil { + // A grouping command that also runs, such as `clear`, only accepts its own + // subcommands; anything else is a typo worth naming. + return Result{}, errors.Errorf("%s does not understand %q, try one of: %s", + path, args, strings.Join(childNames(command), ", ")) + } + return command.Run(ctx, env, args) +} + +// exactChild returns the child of command whose name is exactly name. +func exactChild(command *Command, name string) *Command { + for _, child := range command.Sub { + if child.Name == name { + return child + } + } + return nil +} + +// childNames lists a command's subcommand names. +func childNames(command *Command) []string { + names := make([]string, 0, len(command.Sub)) + for _, child := range command.Sub { + names = append(names, child.Name) + } + return names +} + +// firstWord returns the first space-separated word of s. +func firstWord(s string) string { + if before, _, ok := strings.Cut(s, " "); ok { + return before + } + return s +} + +// Complete returns whole replacement lines for the text typed so far. +// +// Whole lines, not fragments: [tui.Prompt] hands the candidates to bubbles/textinput, which +// matches a candidate by testing it as a case-insensitive prefix of the ENTIRE field value. A +// candidate of "temp/1" for the line "read-direct demo://plant-1 te" would match nothing. +// +// An empty line returns nothing. Offering the whole command list before a single key has been +// pressed pushed a popup over the panes on every start, which the previous implementation +// avoided in the same way. +func (r *Registry) Complete(env *Env, line string) []string { + if strings.TrimSpace(line) == "" { + return nil + } + + command, path, args := r.Resolve(line) + prefix := "" + if path != "" { + prefix = path + " " + } + + var candidates []string + // Subcommands whose name continues the word being typed. + partialWord := firstWord(args) + for _, child := range command.Sub { + if strings.HasPrefix(child.Name, partialWord) { + candidates = append(candidates, prefix+child.Name) + } + } + // Argument suggestions for the command itself, and for a subcommand whose name is complete + // but which Resolve left unconsumed because no space follows it yet. + candidates = append(candidates, suggestionsFor(command, env, prefix, args)...) + if child := exactChild(command, partialWord); child != nil { + candidates = append(candidates, suggestionsFor(child, env, prefix+child.Name+" ", "")...) + } + + // Only candidates that continue what the user has already typed are useful, since anything + // else is filtered out by textinput anyway and merely inflates the "n more" counter. + lowerLine := strings.ToLower(line) + out := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + if !strings.HasPrefix(strings.ToLower(candidate), lowerLine) { + continue + } + if slices.Contains(out, candidate) { + continue + } + out = append(out, candidate) + } + return out +} + +// suggestionsFor collects one command's argument suggestions, prefixed with its path. +func suggestionsFor(command *Command, env *Env, prefix, partial string) []string { + if command.Run == nil || command.Suggest == nil { + return nil + } + suggestions := command.Suggest(env, partial) + out := make([]string, 0, len(suggestions)) + for _, suggestion := range suggestions { + out = append(out, prefix+suggestion) + } + return out +} + +// Walk visits every command depth-first, deepest last, with its indent level and full path. +// The root itself is not visited: it has no name a user could type. +func (r *Registry) Walk(visit func(depth int, path string, command *Command)) { + var recurse func(depth int, path string, command *Command) + recurse = func(depth int, path string, command *Command) { + if command != r.root { + visit(depth, path, command) + } + for _, child := range command.Sub { + childPath := child.Name + if command != r.root { + childPath = path + " " + child.Name + } + recurse(depth+1, childPath, child) + } + } + recurse(-1, "", r.root) +} diff --git a/plc4go/tools/plc4xbrowser/browser/commands.go b/plc4go/tools/plc4xbrowser/browser/commands.go new file mode 100644 index 000000000..d5b7ca355 --- /dev/null +++ b/plc4go/tools/plc4xbrowser/browser/commands.go @@ -0,0 +1,644 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package browser + +import ( + "context" + "fmt" + "net/url" + "strconv" + "strings" + "time" + + "github.com/apache/plc4x/plc4go/spi/errors" + "github.com/rs/zerolog" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/plcsession" +) + +// The command tree. +// +// Every command the previous implementation advertised is here, with the same name and the +// same meaning, because a user's muscle memory and their saved command history both survive +// the port. Four of them - read, write, browse and subscribe - are new work rather than a +// port: they existed only as an error message, returning "mode switch not yet implemented", +// so the tool's headline capability had never actually shipped. + +// NewCommands builds the command tree. +func NewCommands() *Registry { + root := &Command{Name: rootName} + root.Sub = []*Command{ + discoverCommand(), + connectCommand(), + disconnectCommand(), + registerCommand(), + + composeCommand(OperationRead, "read", "Compose a read request against a connection"), + composeCommand(OperationWrite, "write", "Compose a write request against a connection"), + composeCommand(OperationBrowse, "browse", "Compose a browse request against a connection"), + composeCommand(OperationSubscribe, "subscribe", "Compose a subscription against a connection"), + + readDirectCommand(), + writeDirectCommand(), + browseDirectCommand(), + subscribeDirectCommand(), + + logCommand(), + historyCommand(), + clearCommand(), + helpCommand(), + quitCommand(), + } + return NewRegistry(root) +} + +// rootName labels the synthetic root of the tree. +const rootName = "plc4xbrowser" + +// discoverCommand searches for devices speaking a protocol. +func discoverCommand() *Command { + return &Command{ + Name: "discover", + Description: "Discover devices speaking a protocol", + Args: "[protocol]", + Suggest: func(env *Env, partial string) []string { + return prefixed(env.Session.Protocols(), partial) + }, + Run: func(ctx context.Context, env *Env, args string) (Result, error) { + protocol := strings.TrimSpace(args) + if protocol == "" { + return Result{}, errors.Errorf("discover needs a protocol, one of %s", + strings.Join(env.Session.Protocols(), ", ")) + } + var result Result + err := env.Session.Discover(ctx, protocol, func(item plcsession.DiscoveryItem) { + result.Lines = append(result.Lines, + fmt.Sprintf("found %s (%s)", item.ConnectionString, item.Name)) + result.Events = append(result.Events, plcsession.Event{ + Kind: plcsession.EventDiscover, + Connection: item.ConnectionString, + Received: env.Now(), + Summary: item.Name, + }) + }) + if err != nil { + return Result{}, err + } + if len(result.Events) == 0 { + result.Lines = append(result.Lines, "no devices found") + } + return result, nil + }, + } +} + +// connectCommand opens a connection. +func connectCommand() *Command { + return &Command{ + Name: "connect", + Description: "Connect to a device", + Args: "[connection string]", + Suggest: suggestConnectTargets, + Run: func(ctx context.Context, env *Env, args string) (Result, error) { + connectionString := strings.TrimSpace(args) + if connectionString == "" { + return Result{}, errors.New("connect needs a connection string") + } + info, err := env.Session.Connect(ctx, connectionString) + if err != nil { + return Result{}, err + } + // Remember the host so the next session can suggest it, as the old tool did. + if parsed, parseErr := url.Parse(connectionString); parseErr == nil && parsed.Host != "" { + env.Config.AddHost(parsed.Host) + } + return Result{ + Lines: []string{"connected " + info.ID}, + ConnectionsChanged: true, + }, nil + }, + } +} + +// disconnectCommand closes a connection. +func disconnectCommand() *Command { + return &Command{ + Name: "disconnect", + Description: "Disconnect a connection", + Args: "[connection]", + Suggest: suggestConnections, + Run: func(_ context.Context, env *Env, args string) (Result, error) { + id := strings.TrimSpace(args) + if id == "" { + return Result{}, errors.New("disconnect needs a connection") + } + if err := env.Session.Disconnect(id); err != nil { + return Result{}, err + } + return Result{ + Lines: []string{"disconnected " + id}, + ConnectionsChanged: true, + }, nil + }, + } +} + +// registerCommand registers a protocol driver. +func registerCommand() *Command { + return &Command{ + Name: "register", + Description: "Register a protocol driver", + Args: "[protocol]", + Suggest: func(env *Env, partial string) []string { + return prefixed(env.Session.Protocols(), partial) + }, + Run: func(_ context.Context, env *Env, args string) (Result, error) { + protocol := strings.TrimSpace(args) + if protocol == "" { + return Result{}, errors.Errorf("register needs a protocol, one of %s", + strings.Join(env.Session.Protocols(), ", ")) + } + info, err := env.Session.RegisterDriver(protocol) + if err != nil { + return Result{}, err + } + return Result{ + Lines: []string{"registered " + info.Code + " (" + info.Name + ")"}, + DriversChanged: true, + }, nil + }, + } +} + +// composeCommand opens the request composer for one operation. +// +// These four are the commands the previous implementation never finished. Rather than +// executing immediately, they hand the model a ComposeSpec: a read or a write is a multi-tag +// request, and typing several tag/address pairs on one line is exactly the interface the +// -direct variants already provide badly. +func composeCommand(operation Operation, name, description string) *Command { + return &Command{ + Name: name, + Description: description, + Args: "[connection] [tag address...]", + Suggest: suggestConnectionThenTag, + Run: func(_ context.Context, env *Env, args string) (Result, error) { + connection, rest := splitFirst(strings.TrimSpace(args)) + if connection != "" { + if err := requireConnected(env, connection); err != nil { + return Result{}, err + } + } + spec := &ComposeSpec{Operation: operation, Connection: connection} + if operation == OperationBrowse { + spec.Query = strings.TrimSpace(rest) + } else { + for i, address := range strings.Fields(rest) { + spec.Tags = append(spec.Tags, plcsession.TagSpec{ + Name: "tag" + strconv.Itoa(i+1), + Address: address, + }) + } + } + return Result{Compose: spec}, nil + }, + } +} + +// readDirectCommand reads one tag without opening the composer. +func readDirectCommand() *Command { + return &Command{ + Name: "read-direct", + Description: "Read one tag from a connection", + Args: "[connection] [tag address]", + Suggest: suggestConnectionThenTag, + Run: func(ctx context.Context, env *Env, args string) (Result, error) { + connection, address, err := connectionAndAddress(env, args, "read-direct") + if err != nil { + return Result{}, err + } + started := env.Now() + read, err := env.Session.Read(ctx, connection, []plcsession.TagSpec{ + {Name: "readTag", Address: address}, + }) + if err != nil { + return Result{}, err + } + return resultForTags(env, plcsession.EventRead, connection, read.Tags, read.Duration, started), nil + }, + } +} + +// writeDirectCommand writes one tag without opening the composer. +func writeDirectCommand() *Command { + return &Command{ + Name: "write-direct", + Description: "Write one tag on a connection", + Args: "[connection] [tag address] [value]", + Suggest: suggestConnectionThenTag, + Run: func(ctx context.Context, env *Env, args string) (Result, error) { + fields := strings.Fields(strings.TrimSpace(args)) + if len(fields) < 3 { + return Result{}, errors.New("write-direct needs a connection, a tag address and a value") + } + connection, address, value := fields[0], fields[1], strings.Join(fields[2:], " ") + if err := requireConnected(env, connection); err != nil { + return Result{}, err + } + started := env.Now() + write, err := env.Session.Write(ctx, connection, []plcsession.TagSpec{ + {Name: "writeTag", Address: address, Value: value}, + }) + if err != nil { + return Result{}, err + } + return resultForTags(env, plcsession.EventWrite, connection, write.Tags, write.Duration, started), nil + }, + } +} + +// browseDirectCommand browses a connection without opening the composer. +func browseDirectCommand() *Command { + return &Command{ + Name: "browse-direct", + Description: "Browse the tags a connection exposes", + Args: "[connection] [query]", + Suggest: suggestConnections, + Run: func(ctx context.Context, env *Env, args string) (Result, error) { + connection, query := splitFirst(strings.TrimSpace(args)) + if connection == "" { + return Result{}, errors.New("browse-direct needs a connection") + } + if err := requireConnected(env, connection); err != nil { + return Result{}, err + } + started := env.Now() + browse, err := env.Session.Browse(ctx, connection, strings.TrimSpace(query)) + if err != nil { + return Result{}, err + } + result := Result{Lines: []string{ + fmt.Sprintf("browse %s found %d tags in %s", connection, len(browse.Items), browse.Duration), + }} + // One message for the whole browse, not one per tag. Emitting a message per tag + // meant browsing a device with eight tags pushed eight rows into the list, all with + // the same timestamp, and shoved the events the user was actually watching off the + // screen. The tags belong in the detail pane, which is what it is for. + if len(browse.Items) > 0 { + tags := make([]plcsession.TagResult, 0, len(browse.Items)) + for _, item := range browse.Items { + tags = append(tags, plcsession.TagResult{ + Name: item.Name, + Address: item.Address, + DataType: item.DataType, + Value: browseAccess(item), + Code: plcsession.ResponseCodeOK, + }) + } + result.Events = append(result.Events, plcsession.Event{ + Kind: plcsession.EventBrowse, + Connection: connection, + Started: started, + Received: env.Now(), + Tags: tags, + Summary: fmt.Sprintf("%d tags", len(tags)), + }) + } + if len(browse.Items) == 0 { + result.Lines = append(result.Lines, "no tags matched") + } + return result, nil + }, + } +} + +// subscribeDirectCommand subscribes to one tag without opening the composer. +func subscribeDirectCommand() *Command { + return &Command{ + Name: "subscribe-direct", + Description: "Subscribe to one tag on a connection", + Args: "[connection] [tag address]", + Suggest: suggestConnectionThenTag, + Run: func(_ context.Context, env *Env, args string) (Result, error) { + connection, address, err := connectionAndAddress(env, args, "subscribe-direct") + if err != nil { + return Result{}, err + } + // A subscription outlives the command that started it, so it needs a context that + // is not the command's own; the command's is cancelled the moment it returns. + streamCtx, cancel := env.StreamContext() + events, err := env.Session.Subscribe(streamCtx, connection, []plcsession.TagSpec{ + {Name: "subscriptionTag", Address: address}, + }) + if err != nil { + cancel() + return Result{}, err + } + return Result{ + Lines: []string{"subscribed " + connection + " " + address}, + Stream: &Stream{ + Connection: connection, + Label: address, + Events: events, + Cancel: cancel, + }, + }, nil + }, + } +} + +// logCommand groups the log-related subcommands. +func logCommand() *Command { + return &Command{ + Name: "log", + Description: "Log related operations", + Sub: []*Command{ + { + Name: "level", + Description: "Set the log level", + Args: "[level]", + Suggest: func(_ *Env, partial string) []string { + return prefixed(logLevelNames(), partial) + }, + Run: func(_ context.Context, env *Env, args string) (Result, error) { + name := strings.TrimSpace(args) + level, err := zerolog.ParseLevel(name) + if err != nil { + return Result{}, errors.Errorf("unknown log level %q, one of %s", + name, strings.Join(logLevelNames(), ", ")) + } + env.Config.LogLevel = level.String() + return Result{ + Lines: []string{"log level " + level.String()}, + LogLevel: &level, + }, nil + }, + }, + { + Name: "clear", + Description: "Clear the log pane", + Run: func(_ context.Context, _ *Env, _ string) (Result, error) { + return Result{Clear: ClearConsole}, nil + }, + }, + }, + } +} + +// historyCommand shows the recent command history. +func historyCommand() *Command { + return &Command{ + Name: "history", + Description: "Show the recent command history", + Run: func(_ context.Context, env *Env, _ string) (Result, error) { + recent := env.Config.History.Last10Commands + if len(recent) == 0 { + return Result{Lines: []string{"no commands yet"}}, nil + } + lines := make([]string, 0, len(recent)+1) + lines = append(lines, "recent commands:") + for i, command := range recent { + lines = append(lines, fmt.Sprintf(" %d: %s", i, command)) + } + return Result{Lines: lines}, nil + }, + } +} + +// clearCommand empties the output panes. +func clearCommand() *Command { + return &Command{ + Name: "clear", + Description: "Clear the output panes", + Args: "[messages|console|all]", + Suggest: func(_ *Env, partial string) []string { + return prefixed([]string{"messages", "console", "all"}, partial) + }, + Run: func(_ context.Context, _ *Env, args string) (Result, error) { + switch strings.TrimSpace(args) { + case "", "all": + return Result{Clear: ClearAll}, nil + case "messages": + return Result{Clear: ClearMessages}, nil + case "console": + return Result{Clear: ClearConsole}, nil + default: + return Result{}, errors.Errorf("clear takes messages, console or all") + } + }, + } +} + +// helpCommand lists the command tree. +func helpCommand() *Command { + return &Command{ + Name: "help", + Description: "List the available commands", + Run: func(_ context.Context, env *Env, _ string) (Result, error) { + var lines []string + // Walk skips the root by identity, so every command it visits belongs in the + // listing; depth 0 is a top-level command, not the root. + env.Registry.Walk(func(depth int, path string, command *Command) { + entry := strings.Repeat(" ", depth) + path + if command.Args != "" { + entry += " " + command.Args + } + if command.Description != "" { + entry += " — " + command.Description + } + lines = append(lines, entry) + }) + return Result{Lines: lines}, nil + }, + } +} + +// quitCommand exits the application. +func quitCommand() *Command { + return &Command{ + Name: "quit", + Description: "Quit the application", + Run: func(_ context.Context, _ *Env, _ string) (Result, error) { + return Result{Quit: true}, nil + }, + } +} + +// --- shared helpers --- + +// requireConnected reports a useful error when a command names a connection that is not open. +func requireConnected(env *Env, id string) error { + for _, connection := range env.Session.Connections() { + if connection.ID == id { + return nil + } + } + open := connectionIDs(env) + if len(open) == 0 { + return errors.Errorf("%s is not connected, and nothing else is either; connect first", id) + } + return errors.Errorf("%s is not connected; open connections are %s", id, strings.Join(open, ", ")) +} + +// connectionAndAddress parses the "[connection] [address]" argument shape the -direct commands +// share, reporting which command complained so the message is actionable. +func connectionAndAddress(env *Env, args, command string) (connection, address string, err error) { + fields := strings.Fields(strings.TrimSpace(args)) + if len(fields) != 2 { + return "", "", errors.Errorf("%s needs exactly two arguments: a connection and a tag address", command) + } + if err := requireConnected(env, fields[0]); err != nil { + return "", "", err + } + return fields[0], fields[1], nil +} + +// resultForTags turns a read or write outcome into console lines and a message-table entry. +func resultForTags(env *Env, kind plcsession.EventKind, connection string, tags []plcsession.TagResult, duration time.Duration, started time.Time) Result { + failed := 0 + for _, tag := range tags { + if !tag.Succeeded() { + failed++ + } + } + summary := fmt.Sprintf("%s %s: %d tags in %s", kind, connection, len(tags), duration) + if failed > 0 { + summary += fmt.Sprintf(", %d failed", failed) + } + return Result{ + Lines: []string{summary}, + Events: []plcsession.Event{{ + Kind: kind, + Connection: connection, + Started: started, + Received: env.Now(), + Tags: tags, + Summary: summary, + }}, + } +} + +// browseAccess renders a browsed tag's access flags as rws, which is what a user needs before +// choosing to read, write or subscribe to it. +func browseAccess(item plcsession.BrowseItem) string { + var access strings.Builder + for _, flag := range []struct { + enabled bool + letter string + }{{item.Readable, "r"}, {item.Writable, "w"}, {item.Subscribable, "s"}} { + if flag.enabled { + access.WriteString(flag.letter) + } else { + access.WriteString("-") + } + } + // The address and the data type are already columns of their own wherever this is shown, so + // repeating them here rendered each tag twice over. + if item.Name != "" { + return access.String() + " " + item.Name + } + return access.String() +} + +// suggestConnections offers the open connections. +func suggestConnections(env *Env, partial string) []string { + return prefixed(connectionIDs(env), partial) +} + +// suggestConnectTargets offers protocols, and the hosts this user connected to before. +// +// The recalled hosts are what makes the prompt worth using on a second run: the old tool kept +// the same history and this port keeps reading it. +func suggestConnectTargets(env *Env, partial string) []string { + var candidates []string + for _, protocol := range env.Session.Protocols() { + candidates = append(candidates, protocol+"://") + for _, host := range env.Config.History.Last10Hosts { + candidates = append(candidates, protocol+"://"+host) + } + } + if env.Demo { + // In demo mode the only reachable devices are the simulated ones, so offer those first + // rather than a protocol prefix that cannot connect to anything. + candidates = append([]string{plcsession.DemoDeviceOne, plcsession.DemoDeviceTwo}, candidates...) + } + return prefixed(candidates, partial) +} + +// suggestConnectionThenTag offers connections until one has been typed, then tag addresses. +func suggestConnectionThenTag(env *Env, partial string) []string { + connection, rest := splitFirst(partial) + if rest == "" && !strings.HasSuffix(partial, " ") { + return prefixed(connectionIDs(env), partial) + } + // A tag address is being typed. In demo mode the catalogue is known, so offer it; against a + // real device the addresses are protocol-specific and browsing is how you find them. + var addresses []string + if env.Demo { + addresses = plcsession.DemoTagAddresses() + } + out := make([]string, 0, len(addresses)) + for _, address := range addresses { + candidate := connection + " " + address + if strings.HasPrefix(candidate, partial) { + out = append(out, candidate) + } + } + return out +} + +// connectionIDs lists the open connection identifiers. +func connectionIDs(env *Env) []string { + connections := env.Session.Connections() + ids := make([]string, 0, len(connections)) + for _, connection := range connections { + ids = append(ids, connection.ID) + } + return ids +} + +// prefixed filters candidates by prefix, which is the matching bubbles/textinput performs. +func prefixed(candidates []string, partial string) []string { + if partial == "" { + return candidates + } + out := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + if strings.HasPrefix(candidate, partial) { + out = append(out, candidate) + } + } + return out +} + +// splitFirst splits off the first whitespace-separated word. +func splitFirst(s string) (first, rest string) { + trimmed := strings.TrimLeft(s, " ") + index := strings.IndexAny(trimmed, " \t") + if index < 0 { + return trimmed, "" + } + return trimmed[:index], strings.TrimLeft(trimmed[index:], " ") +} + +// logLevelNames lists the levels `log level` accepts. +func logLevelNames() []string { + return []string{"trace", "debug", "info", "warn", "error", "fatal", "panic", "disabled"} +} diff --git a/plc4go/tools/plc4xbrowser/browser/commands_test.go b/plc4go/tools/plc4xbrowser/browser/commands_test.go new file mode 100644 index 000000000..be2d2f1ff --- /dev/null +++ b/plc4go/tools/plc4xbrowser/browser/commands_test.go @@ -0,0 +1,396 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package browser + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/plcsession" +) + +// The command layer is tested against the demo session rather than a bespoke mock: it is the +// same implementation --demo runs on, so these tests exercise the path a user actually takes. + +// testEnv builds an environment over a demo session, connected to the first demo device. +func testEnv(t *testing.T) (*Env, *Registry) { + t.Helper() + + session := plcsession.NewDemo(plcsession.DemoOptions{ + SubscriptionInterval: time.Millisecond, + Now: steppingClock(), + }) + t.Cleanup(func() { _ = session.Close() }) + _, err := session.RegisterDriver(plcsession.DemoProtocol) + require.NoError(t, err) + _, err = session.Connect(t.Context(), plcsession.DemoDeviceOne) + require.NoError(t, err) + + config := NewConfig() + registry := NewCommands() + env := &Env{ + Session: session, + Config: &config, + Now: steppingClock(), + Registry: registry, + Demo: true, + StreamContext: func() (context.Context, context.CancelFunc) { + return context.WithCancel(context.Background()) + }, + } + return env, registry +} + +// steppingClock returns timestamps that advance, so ordering is observable without wall time. +func steppingClock() func() time.Time { + base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + step := 0 + return func() time.Time { + step++ + return base.Add(time.Duration(step) * time.Second) + } +} + +// TestEveryAdvertisedCommandIsReachable guards the inventory. The port had to preserve every +// command the previous tool offered, and a command that the registry cannot resolve is a +// command the user's saved history can no longer run. +func TestEveryAdvertisedCommandIsReachable(t *testing.T) { + _, registry := testEnv(t) + for _, name := range []string{ + "discover", "connect", "disconnect", "register", + "read", "write", "browse", "subscribe", + "read-direct", "write-direct", "browse-direct", "subscribe-direct", + "log level", "log clear", + "history", "clear", "help", "quit", + } { + t.Run(name, func(t *testing.T) { + // A trailing space matters: Resolve deliberately leaves a bare final word + // unconsumed, because a word still being typed belongs to completion rather than + // to dispatch. That is what keeps "read" and "read-direct" apart. + command, path, _ := registry.Resolve(name + " ") + require.NotNil(t, command, "%q must resolve to a command", name) + assert.Equal(t, name, strings.TrimSpace(path)) + assert.NotNil(t, command.Run, "%q must be runnable, not just a grouping node", name) + }) + } +} + +// TestLogIsAGroupingCommand documents the one node that is deliberately not runnable. +func TestLogIsAGroupingCommand(t *testing.T) { + _, registry := testEnv(t) + command, path, _ := registry.Resolve("log ") + require.NotNil(t, command) + assert.Equal(t, "log", path) + assert.Nil(t, command.Run, "log only groups its subcommands") + assert.NotEmpty(t, command.Sub) +} + +func TestEveryRunnableCommandHasHelpText(t *testing.T) { + _, registry := testEnv(t) + registry.Walk(func(depth int, path string, command *Command) { + if depth == 0 { + return + } + assert.NotEmpty(t, command.Description, "%s needs a description; it appears in help", path) + }) +} + +// TestTheFourPreviouslyUnimplementedCommandsNowDoSomething is the regression test for the +// headline defect: read, write, browse and subscribe each answered "mode switch not yet +// implemented" and did nothing at all. +func TestTheFourPreviouslyUnimplementedCommandsNowDoSomething(t *testing.T) { + for _, name := range []string{"read", "write", "browse", "subscribe"} { + t.Run(name, func(t *testing.T) { + env, registry := testEnv(t) + result, err := registry.Execute(t.Context(), env, name+" "+plcsession.DemoDeviceOne) + require.NoError(t, err, "%s must not fail", name) + require.NotNil(t, result.Compose, "%s must open the request composer", name) + assert.Equal(t, Operation(name), result.Compose.Operation) + assert.Equal(t, plcsession.DemoDeviceOne, result.Compose.Connection) + + // The old implementation's error text must be gone entirely. + assert.NotContains(t, strings.Join(result.Lines, " "), "not yet implemented") + }) + } +} + +func TestComposeCommandsRejectAnUnknownConnection(t *testing.T) { + env, registry := testEnv(t) + _, err := registry.Execute(t.Context(), env, "read demo://nowhere") + require.Error(t, err) + assert.Contains(t, err.Error(), "not connected") +} + +func TestComposeCommandPrefillsTagsFromTheCommandLine(t *testing.T) { + env, registry := testEnv(t) + result, err := registry.Execute(t.Context(), env, "read "+plcsession.DemoDeviceOne+" temp/1 press/1") + require.NoError(t, err) + require.NotNil(t, result.Compose) + require.Len(t, result.Compose.Tags, 2) + assert.Equal(t, "temp/1", result.Compose.Tags[0].Address) + assert.Equal(t, "press/1", result.Compose.Tags[1].Address) +} + +func TestBrowseComposePrefillsTheQueryRatherThanTags(t *testing.T) { + env, registry := testEnv(t) + result, err := registry.Execute(t.Context(), env, "browse "+plcsession.DemoDeviceOne+" temp") + require.NoError(t, err) + require.NotNil(t, result.Compose) + assert.Equal(t, "temp", result.Compose.Query) + assert.Empty(t, result.Compose.Tags) +} + +func TestReadDirectProducesAMessageEntry(t *testing.T) { + env, registry := testEnv(t) + result, err := registry.Execute(t.Context(), env, "read-direct "+plcsession.DemoDeviceOne+" temp/1") + require.NoError(t, err) + require.Len(t, result.Events, 1) + + event := result.Events[0] + assert.Equal(t, plcsession.EventRead, event.Kind) + assert.Equal(t, plcsession.DemoDeviceOne, event.Connection) + require.Len(t, event.Tags, 1) + assert.True(t, event.Tags[0].Succeeded()) + assert.NotEmpty(t, event.Tags[0].Value, "a read that shows no value is not worth showing") + assert.NotEmpty(t, result.Lines) +} + +func TestWriteDirectIsObservableByAFollowingRead(t *testing.T) { + env, registry := testEnv(t) + _, err := registry.Execute(t.Context(), env, "write-direct "+plcsession.DemoDeviceOne+" motor/speed 1234") + require.NoError(t, err) + + result, err := registry.Execute(t.Context(), env, "read-direct "+plcsession.DemoDeviceOne+" motor/speed") + require.NoError(t, err) + require.Len(t, result.Events, 1) + assert.Equal(t, "1234", result.Events[0].Tags[0].Value) +} + +func TestBrowseDirectListsTags(t *testing.T) { + env, registry := testEnv(t) + result, err := registry.Execute(t.Context(), env, "browse-direct "+plcsession.DemoDeviceOne) + require.NoError(t, err) + assert.NotEmpty(t, result.Events, "browsing the demo device should find its catalogue") + assert.Contains(t, strings.Join(result.Lines, " "), "found") +} + +func TestSubscribeDirectReturnsAStreamTheModelCanDrain(t *testing.T) { + env, registry := testEnv(t) + result, err := registry.Execute(t.Context(), env, "subscribe-direct "+plcsession.DemoDeviceOne+" temp/1") + require.NoError(t, err) + require.NotNil(t, result.Stream) + require.NotNil(t, result.Stream.Cancel) + t.Cleanup(result.Stream.Cancel) + + select { + case event, ok := <-result.Stream.Events: + require.True(t, ok, "the stream closed before delivering anything") + assert.Equal(t, plcsession.EventSubscribe, event.Kind) + case <-time.After(2 * time.Second): + t.Fatal("the subscription delivered no event") + } + + // Cancelling must close the channel: a stream nobody closes is a leak in the model. + result.Stream.Cancel() + deadline := time.After(2 * time.Second) + for { + select { + case _, ok := <-result.Stream.Events: + if !ok { + return + } + case <-deadline: + t.Fatal("cancelling the stream did not close it") + } + } +} + +func TestDirectCommandsRejectTheWrongArgumentCount(t *testing.T) { + env, registry := testEnv(t) + for _, line := range []string{ + "read-direct", + "read-direct " + plcsession.DemoDeviceOne, + "read-direct " + plcsession.DemoDeviceOne + " a b", + "write-direct " + plcsession.DemoDeviceOne + " motor/speed", + "subscribe-direct " + plcsession.DemoDeviceOne, + } { + t.Run(line, func(t *testing.T) { + _, err := registry.Execute(t.Context(), env, line) + assert.Error(t, err) + }) + } +} + +// TestNotConnectedErrorsNameTheOpenConnections keeps the failure actionable, rather than the +// bare "%s not connected" the old tool produced. +func TestNotConnectedErrorsNameTheOpenConnections(t *testing.T) { + env, registry := testEnv(t) + _, err := registry.Execute(t.Context(), env, "read-direct demo://nowhere temp/1") + require.Error(t, err) + assert.Contains(t, err.Error(), plcsession.DemoDeviceOne, "the error should say what IS connected") +} + +func TestConnectAndDisconnectUpdateTheSidebar(t *testing.T) { + env, registry := testEnv(t) + + result, err := registry.Execute(t.Context(), env, "connect "+plcsession.DemoDeviceTwo) + require.NoError(t, err) + assert.True(t, result.ConnectionsChanged, "the sidebar must be told to re-read") + + result, err = registry.Execute(t.Context(), env, "disconnect "+plcsession.DemoDeviceTwo) + require.NoError(t, err) + assert.True(t, result.ConnectionsChanged) +} + +func TestConnectRemembersTheHostForNextTime(t *testing.T) { + env, registry := testEnv(t) + _, err := registry.Execute(t.Context(), env, "connect "+plcsession.DemoDeviceTwo) + require.NoError(t, err) + assert.Contains(t, env.Config.History.Last10Hosts, "plant-2", + "the connect history is what makes the prompt useful on a second run") +} + +func TestRegisterReportsTheDriver(t *testing.T) { + env, registry := testEnv(t) + // The demo driver is already registered by the fixture, so registering again must complain. + _, err := registry.Execute(t.Context(), env, "register "+plcsession.DemoProtocol) + assert.Error(t, err, "registering twice should be reported rather than silently ignored") +} + +func TestLogLevelSetsTheLevelAndPersistsIt(t *testing.T) { + env, registry := testEnv(t) + result, err := registry.Execute(t.Context(), env, "log level debug") + require.NoError(t, err) + require.NotNil(t, result.LogLevel) + assert.Equal(t, "debug", result.LogLevel.String()) + assert.Equal(t, "debug", env.Config.LogLevel, "the level must survive into the saved config") +} + +func TestLogLevelRejectsAnUnknownLevelAndSaysWhatIsValid(t *testing.T) { + env, registry := testEnv(t) + _, err := registry.Execute(t.Context(), env, "log level shouty") + require.Error(t, err) + assert.Contains(t, err.Error(), "debug") +} + +func TestClearTargetsTheRightPanes(t *testing.T) { + env, registry := testEnv(t) + for line, want := range map[string]ClearTarget{ + "clear": ClearAll, + "clear all": ClearAll, + "clear messages": ClearMessages, + "clear console": ClearConsole, + "log clear": ClearConsole, + } { + t.Run(line, func(t *testing.T) { + result, err := registry.Execute(t.Context(), env, line) + require.NoError(t, err) + assert.Equal(t, want, result.Clear) + }) + } +} + +func TestQuitAsksTheApplicationToExit(t *testing.T) { + env, registry := testEnv(t) + result, err := registry.Execute(t.Context(), env, "quit") + require.NoError(t, err) + assert.True(t, result.Quit) +} + +func TestHelpListsEveryCommand(t *testing.T) { + env, registry := testEnv(t) + result, err := registry.Execute(t.Context(), env, "help") + require.NoError(t, err) + joined := strings.Join(result.Lines, "\n") + for _, name := range []string{"connect", "read-direct", "subscribe", "quit"} { + assert.Contains(t, joined, name) + } +} + +func TestUnknownCommandIsReportedRatherThanIgnored(t *testing.T) { + env, registry := testEnv(t) + _, err := registry.Execute(t.Context(), env, "frobnicate the widget") + assert.Error(t, err) +} + +func TestEmptyLineIsNotAnError(t *testing.T) { + env, registry := testEnv(t) + for _, line := range []string{"", " ", "\t"} { + result, err := registry.Execute(t.Context(), env, line) + assert.NoError(t, err, "an empty prompt submission is a no-op, not a failure") + assert.Empty(t, result.Lines) + } +} + +// --- completion --- + +func TestCompletionOffersTopLevelCommands(t *testing.T) { + env, registry := testEnv(t) + suggestions := registry.Complete(env, "co") + assert.Contains(t, suggestions, "connect") +} + +func TestCompletionOffersSubcommands(t *testing.T) { + env, registry := testEnv(t) + suggestions := registry.Complete(env, "log ") + assert.Contains(t, suggestions, "log level") + assert.Contains(t, suggestions, "log clear") +} + +func TestCompletionOffersOpenConnectionsAsArguments(t *testing.T) { + env, registry := testEnv(t) + suggestions := registry.Complete(env, "read-direct ") + joined := strings.Join(suggestions, " ") + assert.Contains(t, joined, plcsession.DemoDeviceOne, + "the open connection is the only sensible first argument") +} + +func TestCompletionOffersDemoTagsOnceAConnectionIsTyped(t *testing.T) { + env, registry := testEnv(t) + suggestions := registry.Complete(env, "read-direct "+plcsession.DemoDeviceOne+" te") + joined := strings.Join(suggestions, " ") + assert.Contains(t, joined, "temp/1", "demo mode knows its catalogue, so it should offer it") +} + +func TestCompletionOffersTheDemoDevicesToConnectTo(t *testing.T) { + env, registry := testEnv(t) + suggestions := registry.Complete(env, "connect ") + joined := strings.Join(suggestions, " ") + assert.Contains(t, joined, plcsession.DemoDeviceOne, + "in demo mode the reachable devices should lead, not a protocol prefix that connects to nothing") +} + +func TestCompletionReturnsWholeLinesSoThePromptCanAcceptThemDirectly(t *testing.T) { + env, registry := testEnv(t) + for _, suggestion := range registry.Complete(env, "read-direct ") { + assert.True(t, strings.HasPrefix(suggestion, "read-direct "), + "a suggestion is substituted for the whole line, so it must include the command: %q", suggestion) + } +} + +func TestCompletionOfAnUnknownCommandOffersNothing(t *testing.T) { + env, registry := testEnv(t) + assert.Empty(t, registry.Complete(env, "frobnicate ")) +} diff --git a/plc4go/tools/plc4xbrowser/browser/compose.go b/plc4go/tools/plc4xbrowser/browser/compose.go new file mode 100644 index 000000000..eda856efa --- /dev/null +++ b/plc4go/tools/plc4xbrowser/browser/compose.go @@ -0,0 +1,477 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package browser + +import ( + "context" + "strconv" + "strings" + + "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/plcsession" + "github.com/apache/plc4x-extras/plc4go/tools/internal/tui" +) + +// The request composer. +// +// This is the interface behind read, write, browse and subscribe -- the four commands the +// previous implementation advertised in its help and then answered with "mode switch not yet +// implemented". They were never finished because a read is a multi-tag request and the +// single-line prompt cannot express one: the -direct variants exist precisely because the real +// form did not. A modal form is the missing piece. + +// composeField identifies a focusable field in the form. +type composeField int + +const ( + fieldConnection composeField = iota + fieldFirstTag +) + +// composeRow is one tag row of the form. +type composeRow struct { + name string + address string + value string +} + +// composer is the modal request form. +type composer struct { + operation Operation + // connection is the connection the request runs against. + connection string + // query is the browse query; browse has no tag rows. + query string + rows []composeRow + + // field is the focused field index: 0 is the connection, then two or three cells per tag + // row depending on whether the operation writes. + field int + // submitting suppresses further edits while the request is in flight. + submitting bool + // problem is the validation message shown at the foot of the form. + problem string +} + +// cellsPerRow is how many editable cells a tag row has: name and address, plus a value for a +// write. +func (c *composer) cellsPerRow() int { + if c.operation == OperationWrite { + return 3 + } + return 2 +} + +// fieldCount is the number of focusable cells, including the connection and the trailing +// "add tag" affordance. +func (c *composer) fieldCount() int { + if c.operation == OperationBrowse { + // Connection and query. + return 2 + } + return 1 + len(c.rows)*c.cellsPerRow() + 1 +} + +// isAddRow reports whether the focus is on the "+ add tag" affordance. +func (c *composer) isAddRow() bool { + return c.operation != OperationBrowse && c.field == c.fieldCount()-1 +} + +// rowAndCell maps the focused field onto a tag row and the cell within it. +func (c *composer) rowAndCell() (row, cell int, ok bool) { + if c.operation == OperationBrowse || c.field == 0 || c.isAddRow() { + return 0, 0, false + } + offset := c.field - 1 + return offset / c.cellsPerRow(), offset % c.cellsPerRow(), true +} + +// openComposer opens the form for a spec. +func (m *Model) openComposer(spec ComposeSpec) { + form := &composer{ + operation: spec.Operation, + connection: spec.Connection, + query: spec.Query, + } + for _, tag := range spec.Tags { + form.rows = append(form.rows, composeRow{name: tag.Name, address: tag.Address}) + } + if len(form.rows) == 0 && spec.Operation != OperationBrowse { + form.rows = append(form.rows, composeRow{name: "tag1"}) + } + if form.connection == "" { + // Nothing was named on the command line, so start on the connection field; otherwise + // start where the user still has work to do. + form.field = int(fieldConnection) + } else { + form.field = int(fieldFirstTag) + } + m.composer = form + m.prompt.Blur() +} + +// closeComposer dismisses the form and returns the keyboard to the prompt. +func (m *Model) closeComposer() { + m.composer = nil + m.focusPrompt() +} + +// updateComposer handles a key while the form is open. +func (m *Model) updateComposer(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + form := m.composer + if form.submitting { + // The request is in flight. Only escape is honoured, so a stray keystroke cannot edit + // a form that has already been sent. + if msg.String() == "esc" { + m.closeComposer() + } + return m, nil + } + + switch msg.String() { + case "esc": + m.closeComposer() + return m, nil + + case "tab", "down": + form.field = (form.field + 1) % form.fieldCount() + return m, nil + + case "shift+tab", "up": + form.field = (form.field - 1 + form.fieldCount()) % form.fieldCount() + return m, nil + + case "enter": + if form.isAddRow() { + form.addRow() + return m, nil + } + return m.submitComposer() + + case "ctrl+n": + form.addRow() + return m, nil + + case "ctrl+d": + form.removeRow() + return m, nil + + case "backspace": + form.edit(func(current string) string { + if current == "" { + return current + } + runes := []rune(current) + return string(runes[:len(runes)-1]) + }) + return m, nil + } + + // Any other printable key types into the focused cell. + if text := msg.String(); len([]rune(text)) == 1 { + form.edit(func(current string) string { return current + text }) + } + return m, nil +} + +// addRow appends an empty tag row and moves to its first cell. +func (c *composer) addRow() { + if c.operation == OperationBrowse { + return + } + c.rows = append(c.rows, composeRow{name: "tag" + strconv.Itoa(len(c.rows)+1)}) + c.field = 1 + (len(c.rows)-1)*c.cellsPerRow() +} + +// removeRow deletes the focused tag row, keeping at least one. +func (c *composer) removeRow() { + row, _, ok := c.rowAndCell() + if !ok || len(c.rows) <= 1 { + return + } + c.rows = append(c.rows[:row], c.rows[row+1:]...) + if c.field >= c.fieldCount()-1 { + c.field = c.fieldCount() - 2 + } +} + +// edit applies a change to the focused cell. +func (c *composer) edit(change func(string) string) { + if c.field == 0 { + c.connection = change(c.connection) + return + } + if c.operation == OperationBrowse { + c.query = change(c.query) + return + } + row, cell, ok := c.rowAndCell() + if !ok { + return + } + switch cell { + case 0: + c.rows[row].name = change(c.rows[row].name) + case 1: + c.rows[row].address = change(c.rows[row].address) + case 2: + c.rows[row].value = change(c.rows[row].value) + } +} + +// validate reports why the form cannot be submitted, or an empty string when it can. +func (c *composer) validate() string { + if strings.TrimSpace(c.connection) == "" { + return "a connection is required" + } + if c.operation == OperationBrowse { + return "" + } + for i, row := range c.rows { + if strings.TrimSpace(row.address) == "" { + return "tag " + strconv.Itoa(i+1) + " needs an address" + } + if c.operation == OperationWrite && strings.TrimSpace(row.value) == "" { + return "tag " + strconv.Itoa(i+1) + " needs a value to write" + } + } + return "" +} + +// tagSpecs turns the form's rows into a request. +func (c *composer) tagSpecs() []plcsession.TagSpec { + specs := make([]plcsession.TagSpec, 0, len(c.rows)) + for i, row := range c.rows { + name := strings.TrimSpace(row.name) + if name == "" { + name = "tag" + strconv.Itoa(i+1) + } + specs = append(specs, plcsession.TagSpec{ + Name: name, + Address: strings.TrimSpace(row.address), + Value: strings.TrimSpace(row.value), + }) + } + return specs +} + +// submitComposer validates and runs the composed request. +func (m *Model) submitComposer() (tea.Model, tea.Cmd) { + form := m.composer + if problem := form.validate(); problem != "" { + form.problem = problem + return m, nil + } + form.problem = "" + form.submitting = true + + session := m.options.Session + env := m.env + operation := form.operation + connection := strings.TrimSpace(form.connection) + query := strings.TrimSpace(form.query) + tags := form.tagSpecs() + + return m, func() tea.Msg { + ctx := context.Background() + started := env.Now() + switch operation { + case OperationRead: + result, err := session.Read(ctx, connection, tags) + if err != nil { + return commandDoneMsg{err: err} + } + return commandDoneMsg{result: resultForTags(env, plcsession.EventRead, connection, result.Tags, result.Duration, started)} + + case OperationWrite: + result, err := session.Write(ctx, connection, tags) + if err != nil { + return commandDoneMsg{err: err} + } + return commandDoneMsg{result: resultForTags(env, plcsession.EventWrite, connection, result.Tags, result.Duration, started)} + + case OperationBrowse: + result, err := session.Browse(ctx, connection, query) + if err != nil { + return commandDoneMsg{err: err} + } + out := Result{Lines: []string{ + "browse " + connection + " found " + strconv.Itoa(len(result.Items)) + " tags", + }} + // One message for the whole browse; see browseDirectCommand for why. + if len(result.Items) > 0 { + tags := make([]plcsession.TagResult, 0, len(result.Items)) + for _, item := range result.Items { + tags = append(tags, plcsession.TagResult{ + Name: item.Name, + Address: item.Address, + DataType: item.DataType, + Value: browseAccess(item), + Code: plcsession.ResponseCodeOK, + }) + } + out.Events = append(out.Events, plcsession.Event{ + Kind: plcsession.EventBrowse, + Connection: connection, + Started: started, + Received: env.Now(), + Tags: tags, + Summary: strconv.Itoa(len(tags)) + " tags", + }) + } + return commandDoneMsg{result: out} + + case OperationSubscribe: + streamCtx, cancel := env.StreamContext() + events, err := session.Subscribe(streamCtx, connection, tags) + if err != nil { + cancel() + return commandDoneMsg{err: err} + } + label := tags[0].Address + if len(tags) > 1 { + label = tags[0].Address + " +" + strconv.Itoa(len(tags)-1) + } + return commandDoneMsg{result: Result{ + Lines: []string{"subscribed " + connection + " " + label}, + Stream: &Stream{Connection: connection, Label: label, Events: events, Cancel: cancel}, + }} + } + return commandDoneMsg{} + } +} + +// ComposerOpen reports whether the form is showing, for tests. +func (m *Model) ComposerOpen() bool { return m.composer != nil } + +// ComposerSpec reports what the open form is composing, for tests. The zero value when no form +// is open. +func (m *Model) ComposerSpec() ComposeSpec { + if m.composer == nil { + return ComposeSpec{} + } + spec := ComposeSpec{ + Operation: m.composer.operation, + Connection: m.composer.connection, + Query: m.composer.query, + } + for _, row := range m.composer.rows { + spec.Tags = append(spec.Tags, plcsession.TagSpec{ + Name: row.name, + Address: row.address, + Value: row.value, + }) + } + return spec +} + +// ComposerProblem exposes the validation message, for tests. +func (m *Model) ComposerProblem() string { + if m.composer == nil { + return "" + } + return m.composer.problem +} + +// renderComposer draws the form. +func (m *Model) renderComposer(width, height int) string { + form := m.composer + theme := m.theme + glyphs := theme.Glyphs + + // The form is a fixed, readable width rather than the full pane: a form stretched across a + // wide terminal is harder to read, not easier. + formWidth := min(max(width-8, 40), 68) + + title := strings.ToUpper(string(form.operation)[:1]) + string(form.operation)[1:] + " request" + if form.connection != "" { + title += " " + glyphs.Separator + " " + form.connection + } + + var lines []string + lines = append(lines, m.composeCell("connection", form.connection, 0, formWidth-4)) + + if form.operation == OperationBrowse { + lines = append(lines, m.composeCell("query", form.query, 1, formWidth-4)) + } else { + for i, row := range form.rows { + base := 1 + i*form.cellsPerRow() + cells := []string{ + m.composeInline("tag name", row.name, base, 20), + m.composeInline("address", row.address, base+1, 22), + } + if form.operation == OperationWrite { + cells = append(cells, m.composeInline("value", row.value, base+2, 12)) + } + lines = append(lines, " "+strings.Join(cells, " ")) + } + add := " + add tag" + if form.isAddRow() { + add = theme.SelectedRow.Render(" " + glyphs.Selected + " add tag") + } + lines = append(lines, add) + } + + lines = append(lines, "") + switch { + case form.submitting: + lines = append(lines, " "+theme.Muted.Render("runningâ€Ļ")) + case form.problem != "": + lines = append(lines, " "+theme.Err.Render(glyphs.Err+" "+form.problem)) + default: + lines = append(lines, " "+theme.Ok.Render(glyphs.Ok+" ready")+ + " "+theme.Muted.Render("enter run "+glyphs.Separator+" tab next "+glyphs.Separator+" esc cancel")) + } + + body := strings.Join(lines, "\n") + pane := tui.Pane{Title: title, Width: formWidth, Height: len(lines) + 2, Focused: true} + return pane.Render(theme, body) +} + +// composeCell renders a full-width labelled field. +func (m *Model) composeCell(label, value string, field, width int) string { + return " " + m.composeInline(label, value, field, width-len(label)-4) +} + +// composeInline renders one labelled field, marking it when focused. +func (m *Model) composeInline(label, value string, field, width int) string { + theme := m.theme + shown := value + if m.composer.field == field && !m.composer.submitting { + // A block cursor, so the focused cell is unmistakable even with colour off. + shown = value + "█" + } + shown = pad(shown, width) + if m.composer.field == field { + return theme.Muted.Render(label+" ") + theme.SelectedRow.Render(shown) + } + return theme.Muted.Render(label+" ") + theme.Value.Render(shown) +} + +// pad right-pads text to a width, measured in display cells. +func pad(text string, width int) string { + actual := lipgloss.Width(text) + if actual >= width { + return text + } + return text + strings.Repeat(" ", width-actual) +} diff --git a/plc4go/tools/plc4xbrowser/browser/config.go b/plc4go/tools/plc4xbrowser/browser/config.go new file mode 100644 index 000000000..5556a93f3 --- /dev/null +++ b/plc4go/tools/plc4xbrowser/browser/config.go @@ -0,0 +1,183 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package browser + +import ( + "os" + "path/filepath" + "slices" + "time" + + "github.com/apache/plc4x/plc4go/spi/errors" + "gopkg.in/yaml.v3" +) + +// The on-disk settings, carried over from the previous implementation so that an existing +// ~/.config/plc4xbrowser/config.yml keeps working: the field names and the YAML keys are +// unchanged. +// +// What did change is ownership. The previous version kept the settings in a package-level +// variable, loaded them from an init function and panicked from that init when the user's +// config directory could not be created - which aborts the process before main runs, with a +// stack trace and no explanation. Here the path is resolved by a function that returns an +// error, the settings are a value, and a broken or unreadable file degrades to the defaults. + +// HistoryLimit is how many hosts and commands are remembered. It is ten because the YAML keys +// are named for ten and an existing file must keep meaning what it says. +const HistoryLimit = 10 + +// Config is the persisted state of a browser session. +type Config struct { + History struct { + Last10Hosts []string `yaml:"last_hosts"` + Last10Commands []string `yaml:"last_commands"` + } `yaml:"history"` + AutoRegisterDrivers []string `yaml:"auto_register_driver"` + LastUpdated time.Time `yaml:"last_updated"` + LogLevel string `yaml:"log_level"` + MaxConsoleLines int `yaml:"max_console_lines"` + MaxOutputLines int `yaml:"max_output_lines"` +} + +// Default numbers of retained lines, matching the previous implementation. +const ( + defaultMaxConsoleLines = 500 + defaultMaxOutputLines = 500 +) + +// NewConfig returns the defaults. +func NewConfig() Config { + return Config{ + MaxConsoleLines: defaultMaxConsoleLines, + MaxOutputLines: defaultMaxOutputLines, + } +} + +// ConfigPath returns the file the settings live in, creating the directory if it does not +// exist. Unlike the code it replaces it reports a failure rather than panicking, because a +// read-only home directory is a reason to run without persistence, not a reason to refuse to +// start. +func ConfigPath() (string, error) { + userConfigDir, err := os.UserConfigDir() + if err != nil { + return "", errors.Wrap(err, "cannot locate the user config directory") + } + dir := filepath.Join(userConfigDir, "plc4xbrowser") + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", errors.Wrapf(err, "cannot create %s", dir) + } + return filepath.Join(dir, "config.yml"), nil +} + +// LoadConfig reads the settings from path. +// +// A missing file is not an error: it is the first run. A malformed one is reported, but the +// defaults are still returned, so a hand-edited file with a typo in it cannot lock the user +// out of the tool. +func LoadConfig(path string) (Config, error) { + config := NewConfig() + data, err := os.ReadFile(path) //nolint:gosec // the path comes from ConfigPath or a test. + if err != nil { + if os.IsNotExist(err) { + return config, nil + } + return config, errors.Wrapf(err, "cannot read %s", path) + } + if err := yaml.Unmarshal(data, &config); err != nil { + return NewConfig(), errors.Wrapf(err, "cannot parse %s", path) + } + // A file written by an older version, or by hand, may leave these at zero, which would + // mean "retain nothing" rather than "unset". + if config.MaxConsoleLines <= 0 { + config.MaxConsoleLines = defaultMaxConsoleLines + } + if config.MaxOutputLines <= 0 { + config.MaxOutputLines = defaultMaxOutputLines + } + return config, nil +} + +// Save writes the settings to path. +func (c *Config) Save(path string) error { + c.LastUpdated = time.Now() + data, err := yaml.Marshal(c) + if err != nil { + return errors.Wrap(err, "cannot encode the configuration") + } + // Truncating: the previous implementation opened with O_RDWR|O_CREATE and no O_TRUNC, so + // writing a shorter document than the one already there left the tail of the old one + // behind and produced a file that no longer parses. + if err := os.WriteFile(path, data, 0o600); err != nil { + return errors.Wrapf(err, "cannot write %s", path) + } + return nil +} + +// AddHost records a host in the connect history, most recent last. +func (c *Config) AddHost(host string) { + if host == "" { + return + } + c.History.Last10Hosts = pushRecent(c.History.Last10Hosts, host) +} + +// AddCommand records a command line in the history, most recent last. +// +// clear and history are skipped, as they were before: replaying either from the history is +// never what the user meant, and both would otherwise crowd out the ten slots. +func (c *Config) AddCommand(command string) { + switch command { + case "", "clear", "history": + return + } + c.History.Last10Commands = pushRecent(c.History.Last10Commands, command) +} + +// EnableAutoRegister marks a protocol for registration at startup. +func (c *Config) EnableAutoRegister(protocol string) error { + if slices.Contains(c.AutoRegisterDrivers, protocol) { + return errors.Errorf("%s is already registered for auto register", protocol) + } + c.AutoRegisterDrivers = append(c.AutoRegisterDrivers, protocol) + return nil +} + +// DisableAutoRegister unmarks a protocol. +func (c *Config) DisableAutoRegister(protocol string) error { + index := slices.Index(c.AutoRegisterDrivers, protocol) + if index < 0 { + return errors.Errorf("%s is not registered for auto register", protocol) + } + c.AutoRegisterDrivers = slices.Delete(c.AutoRegisterDrivers, index, index+1) + return nil +} + +// pushRecent appends value, removing any earlier occurrence and dropping the oldest entries +// once the limit is passed. +func pushRecent(entries []string, value string) []string { + if index := slices.Index(entries, value); index >= 0 { + entries = slices.Delete(entries, index, index+1) + } + entries = append(entries, value) + if len(entries) > HistoryLimit { + entries = slices.Clone(entries[len(entries)-HistoryLimit:]) + } + return entries +} diff --git a/plc4go/tools/plc4xbrowser/browser/doc.go b/plc4go/tools/plc4xbrowser/browser/doc.go new file mode 100644 index 000000000..da7a72935 --- /dev/null +++ b/plc4go/tools/plc4xbrowser/browser/doc.go @@ -0,0 +1,40 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +// Package browser is the terminal user interface of plc4xbrowser. +// +// It replaces a tview implementation whose command actions wrote their results directly into +// package-level widgets from background goroutines. That arrangement had three consequences +// this package is shaped to avoid. +// +// Nothing could be tested: an action returned an error and otherwise communicated only by +// mutating a widget, so there was no value to assert on. Here a command is data with a Run +// function that takes a [plcsession.Session] and returns a [Result]; the model turns results +// into state and the view renders state. Each of those three steps is separately testable, and +// none of them needs a terminal. +// +// Four advertised commands were never finished. read, write, browse and subscribe each +// answered "mode switch not yet implemented", because the mechanism they needed - a modal +// editor - was never built. It is built here, as the request composer. +// +// And the widgets were mutated off the event loop, which is a data race by construction. No +// goroutine in this package touches model state. Every stream, whether a subscription, the log +// or a command in flight, is drained by a tea.Cmd that reads one value and returns a message +// that re-arms itself. +package browser diff --git a/plc4go/tools/plc4xbrowser/browser/keys_test.go b/plc4go/tools/plc4xbrowser/browser/keys_test.go new file mode 100644 index 000000000..5dac85c1a --- /dev/null +++ b/plc4go/tools/plc4xbrowser/browser/keys_test.go @@ -0,0 +1,709 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package browser + +import ( + "fmt" + "strconv" + "strings" + "testing" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/table" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/plcsession" + "github.com/apache/plc4x-extras/plc4go/tools/internal/tui" + "github.com/apache/plc4x-extras/plc4go/tools/internal/tuitest" +) + +// The browser advertised seven keys in its help and implemented none of them: g/G, the page +// keys, the filter, yank, follow, expand and the log level. That is the dead-affordance defect +// the port existed to remove, reintroduced in the footer, so each one is pinned here. + +// inPane focuses a pane by its number and returns the model. +func inPane(t *testing.T, model *Model, digit rune) *Model { + t.Helper() + press(t, model, tea.KeyPressMsg{Code: digit, Mod: tea.ModAlt}) + _, promptFocused := model.Focused() + require.False(t, promptFocused, "pane %c should have the keyboard", digit) + return model +} + +// withMessages returns a model carrying several distinct messages. +func withMessages(t *testing.T, addresses ...string) *Model { + t.Helper() + model := sized(t, newTestModel(t), 120, 30) + for _, address := range addresses { + _, cmd := model.Update(tui.PromptSubmitMsg{Line: "read-direct " + plcsession.DemoDeviceOne + " " + address}) + runCmd(t, model, cmd) + } + return model +} + +// TestEveryAdvertisedPaneKeyIsHandled is the guard against the help lying again. It walks the +// bindings the footer actually shows and asserts the model reacts to each. +func TestEveryAdvertisedPaneKeyIsHandled(t *testing.T) { + keys := tui.NewKeyMap().ForFocus(tui.FocusPane) + advertised := map[string]key.Binding{ + "Top": keys.Top, "Bottom": keys.Bottom, + "PageUp": keys.PageUp, "PageDown": keys.PageDown, + "Filter": keys.Filter, "Wrap": keys.Wrap, "Yank": keys.Yank, + "Follow": keys.Follow, "Expand": keys.Expand, "LogLevel": keys.LogLevel, + "Select": keys.Select, "Up": keys.Up, "Down": keys.Down, + } + + // Select is left out: enter on the already-selected row is idempotent by design, so a + // "something changed" assertion cannot express it. It has its own tests, as does the + // sidebar's use of it. + delete(advertised, "Select") + + for name, binding := range advertised { + t.Run(name, func(t *testing.T) { + require.NotEmpty(t, binding.Keys(), "%s is advertised, so it must be bound", name) + + model := withMessages(t, "temp/1", "press/1", "flow/1") + model = inPane(t, model, '2') + // Park the cursor mid-list so that both ends have somewhere to go: following keeps + // it on the last row, where G is legitimately a no-op. + press(t, model, tea.KeyPressMsg{Code: 'g', Text: "g"}) + press(t, model, tea.KeyPressMsg{Code: 'j', Text: "j"}) + before := snapshot(model) + + press(t, model, tea.KeyPressMsg{Code: rune(binding.Keys()[0][0]), Text: binding.Keys()[0]}) + assert.NotEqual(t, before, snapshot(model), + "%s (%v) changed nothing; the footer advertises it, so it must do something", + name, binding.Keys()) + }) + } +} + +// snapshot captures the observable state a key press could plausibly change. +func snapshot(m *Model) string { + shown, total := m.EventCount() + focus, promptFocused := m.Focused() + return strings.Join([]string{ + strconv.Itoa(shown), strconv.Itoa(total), strconv.Itoa(int(focus)), + strconv.FormatBool(promptFocused), strconv.FormatBool(m.Following()), + strconv.FormatBool(m.Filtering()), strconv.FormatBool(m.detailBytes), + strconv.FormatBool(m.toastExpanded), m.options.Config.LogLevel, + strconv.Itoa(m.messages.Cursor()), m.yanked, m.PromptValue(), + strconv.FormatBool(m.ComposerOpen()), + }, "|") +} + +func TestTopAndBottomMoveTheMessageCursor(t *testing.T) { + model := withMessages(t, "temp/1", "press/1", "flow/1", "temp/2") + model = inPane(t, model, '2') + + press(t, model, tea.KeyPressMsg{Code: 'g', Text: "g"}) + assert.Zero(t, model.messages.Cursor(), "g goes to the first message") + + press(t, model, tea.KeyPressMsg{Code: 'G', Text: "G"}) + shown, _ := model.EventCount() + assert.Equal(t, shown-1, model.messages.Cursor(), "G goes to the last") +} + +func TestTopStopsFollowingSoTheCursorStaysPut(t *testing.T) { + model := withMessages(t, "temp/1", "press/1") + model = inPane(t, model, '2') + require.True(t, model.Following()) + + press(t, model, tea.KeyPressMsg{Code: 'g', Text: "g"}) + assert.False(t, model.Following(), "moving to the top by hand means taking over from the stream") +} + +func TestFollowKeepsTheNewestMessageSelected(t *testing.T) { + model := withMessages(t, "temp/1", "press/1") + model = inPane(t, model, '2') + + // Turn following off, move away, then a new message must not steal the selection. + press(t, model, tea.KeyPressMsg{Code: 'f', Text: "f"}) + require.False(t, model.Following()) + press(t, model, tea.KeyPressMsg{Code: 'g', Text: "g"}) + at := model.messages.Cursor() + + _, cmd := model.Update(tui.PromptSubmitMsg{Line: "read-direct " + plcsession.DemoDeviceOne + " flow/1"}) + runCmd(t, model, cmd) + assert.Equal(t, at, model.messages.Cursor(), "with following off a new message must not move the cursor") + + press(t, model, tea.KeyPressMsg{Code: 'f', Text: "f"}) + require.True(t, model.Following()) + shown, _ := model.EventCount() + assert.Equal(t, shown-1, model.messages.Cursor(), "turning it back on jumps to the newest") +} + +func TestTheFilterNarrowsAsItIsTypedAndEscapeClearsIt(t *testing.T) { + model := withMessages(t, "temp/1", "press/1", "temp/2") + model = inPane(t, model, '2') + _, total := model.EventCount() + + press(t, model, tea.KeyPressMsg{Code: '/', Text: "/"}) + require.True(t, model.Filtering(), "/ starts the filter line") + + for _, r := range "press" { + press(t, model, tea.KeyPressMsg{Code: r, Text: string(r)}) + } + shown, stillTotal := model.EventCount() + assert.Less(t, shown, total, "the list must narrow as the filter is typed") + assert.Equal(t, total, stillTotal, "and nothing may be discarded") + + press(t, model, tea.KeyPressMsg{Code: tea.KeyEnter}) + assert.False(t, model.Filtering(), "enter commits the filter") + + press(t, model, tea.KeyPressMsg{Code: '/', Text: "/"}) + press(t, model, tea.KeyPressMsg{Code: tea.KeyEscape}) + shown, _ = model.EventCount() + assert.Equal(t, total, shown, "escape clears the filter, showing everything again") +} + +func TestTheFilterLineTakesPrecedenceOverPaneKeys(t *testing.T) { + model := withMessages(t, "temp/1") + model = inPane(t, model, '2') + press(t, model, tea.KeyPressMsg{Code: '/', Text: "/"}) + + // "g" would be a top-of-list jump outside the filter; inside it, it is text. + press(t, model, tea.KeyPressMsg{Code: 'g', Text: "g"}) + assert.True(t, model.Filtering(), "the filter line keeps the keyboard") + assert.Equal(t, "g", model.filterDraft) +} + +func TestYankCopiesTheSelectedMessage(t *testing.T) { + model := withMessages(t, "temp/1") + model = inPane(t, model, '2') + + _, cmd := model.Update(tea.KeyPressMsg{Code: 'y', Text: "y"}) + require.NotNil(t, cmd, "y must produce a clipboard command") + assert.NotEmpty(t, model.yanked) + assert.Contains(t, model.yanked, "temp/1", "the copied text must carry the tag") + // tea.SetClipboard writes an OSC52 sequence, which works over ssh as well as locally. + assert.NotNil(t, cmd()) +} + +func TestLogLevelCyclesAndIsRecorded(t *testing.T) { + model := withMessages(t, "temp/1") + model = inPane(t, model, '2') + + first := model.options.Config.LogLevel + press(t, model, tea.KeyPressMsg{Code: 'L', Text: "L"}) + second := model.options.Config.LogLevel + assert.NotEqual(t, first, second, "L must change the level") + assert.Contains(t, strings.Join(model.LogLines(), "\n"), "log level "+second, + "and say so, since the change is otherwise invisible") +} + +func TestExpandTogglesTheToast(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + _, cmd := model.Update(tui.PromptSubmitMsg{Line: "read-direct demo://nowhere temp/1"}) + runCmd(t, model, cmd) + require.NotEmpty(t, model.Toast()) + + model = inPane(t, model, '2') + require.False(t, model.toastExpanded) + press(t, model, tea.KeyPressMsg{Code: 'e', Text: "e"}) + assert.True(t, model.toastExpanded, "e must expand the error") +} + +// TestRWSComposeFromTheSelectedMessage is the workflow fix: browse, see a tag, act on it +// without retyping the address the pane is already showing. +func TestRWSComposeFromTheSelectedMessage(t *testing.T) { + for pressed, want := range map[rune]Operation{ + 'r': OperationRead, + 'w': OperationWrite, + 's': OperationSubscribe, + } { + t.Run(string(pressed), func(t *testing.T) { + model := withMessages(t, "temp/1") + model = inPane(t, model, '2') + + press(t, model, tea.KeyPressMsg{Code: pressed, Text: string(pressed)}) + require.True(t, model.ComposerOpen(), "%c must open the composer", pressed) + assert.Equal(t, want, model.composer.operation) + assert.Equal(t, plcsession.DemoDeviceOne, model.composer.connection) + require.Len(t, model.composer.rows, 1) + assert.Equal(t, "temp/1", model.composer.rows[0].address, + "the tag must be carried over rather than retyped") + }) + } +} + +func TestComposingFromABrowseCarriesEveryTag(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + _, cmd := model.Update(tui.PromptSubmitMsg{Line: "browse-direct " + plcsession.DemoDeviceOne}) + runCmd(t, model, cmd) + model = inPane(t, model, '2') + + press(t, model, tea.KeyPressMsg{Code: 'r', Text: "r"}) + require.True(t, model.ComposerOpen()) + assert.Greater(t, len(model.composer.rows), 1, + "reading a browse result should offer every tag it found, not just the first") +} + +// TestTheMessageColumnsFitThePaneExactly is the regression test for a table that claimed 48 +// cells in a 40-cell pane at the tool's own reference size. It truncated, and what it +// truncated was the tag -- the field the row exists to show. +func TestTheMessageColumnsFitThePaneExactly(t *testing.T) { + for _, width := range []int{24, 30, 38, 40, 48, 58, 70, 78, 96, 140} { + for _, showConnection := range []bool{false, true} { + name := strconv.Itoa(width) + if showConnection { + name += "+conn" + } + t.Run(name, func(t *testing.T) { + total := 0 + tagWidth := 0 + for _, column := range messageColumns(width, showConnection) { + total += column.Width + assert.Positive(t, column.Width, "%q must not be given zero width", column.Title) + if column.Title == "tag" { + tagWidth = column.Width + } + } + assert.Equal(t, width, total, + "the columns must use exactly the pane's width, not more") + assert.GreaterOrEqual(t, tagWidth, 8, + "the tag is what the row is for; it must never be squeezed below eight cells") + }) + } + } +} + +// TestTheConnectionColumnOnlyAppearsWhenItDistinguishesSomething: with one connection open it +// would repeat the same string on every row while the tag paid for the space. +func TestTheConnectionColumnOnlyAppearsWhenItDistinguishesSomething(t *testing.T) { + hasConnection := func(columns []table.Column) bool { + for _, column := range columns { + if column.Title == "connection" { + return true + } + } + return false + } + + assert.False(t, hasConnection(messageColumns(140, false)), + "one connection open: the column would say the same thing on every row") + assert.True(t, hasConnection(messageColumns(140, true)), + "several connections: now it tells rows apart") + assert.False(t, hasConnection(messageColumns(44, true)), + "narrow: the tag keeps the space even when there are connections to distinguish") +} + +func TestTheModelOffersTheConnectionColumnOnlyWithSeveralConnections(t *testing.T) { + model := sized(t, newTestModel(t), 140, 30) + assert.False(t, model.showConnectionColumn(), "the fixture opens one connection") + + _, cmd := model.Update(tui.PromptSubmitMsg{Line: "connect " + plcsession.DemoDeviceTwo}) + runCmd(t, model, cmd) + assert.True(t, model.showConnectionColumn()) +} + +// TestABrowseIsOneMessageCarryingEveryTag is the flooding fix: one message per tag meant +// browsing an eight-tag device pushed eight rows in and shoved real events off the screen. +func TestABrowseIsOneMessageCarryingEveryTag(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + _, cmd := model.Update(tui.PromptSubmitMsg{Line: "browse-direct " + plcsession.DemoDeviceOne}) + runCmd(t, model, cmd) + + shown, total := model.EventCount() + assert.Equal(t, 1, total, "a browse is one message, however many tags it found") + assert.Equal(t, 1, shown) + + event, ok := model.SelectedEvent() + require.True(t, ok) + assert.Greater(t, len(event.Tags), 1, "and it carries every tag it found") + assert.Equal(t, plcsession.EventBrowse, event.Kind) +} + +// TestTheSidebarKeepsTheBrowseCatalogue: it is a reference you refer back to while reading +// individual tags, which is why it is not simply the selected message's tags -- that would +// show the same list the detail pane is already showing. +func TestTheSidebarKeepsTheBrowseCatalogue(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + _, cmd := model.Update(tui.PromptSubmitMsg{Line: "browse-direct " + plcsession.DemoDeviceOne}) + runCmd(t, model, cmd) + + // While the browse is the selected message the detail pane is already showing this list, so + // the sidebar stays out of the way rather than printing the same eight tags a second time. + rendered := strings.Join(renderLines(t, model), "\n") + assert.NotContains(t, rendered, "TAGS ", + "the sidebar must not repeat the list the detail pane is showing:\n%s", rendered) + + // Move on to a read: now the catalogue earns its space as a reference. + _, cmd = model.Update(tui.PromptSubmitMsg{Line: "read-direct " + plcsession.DemoDeviceOne + " temp/1"}) + runCmd(t, model, cmd) + rendered = strings.Join(renderLines(t, model), "\n") + assert.Contains(t, rendered, "TAGS ", "once the selection moves on, the catalogue is a reference") + assert.Contains(t, rendered, "motor/speed", "and it lists the tags the browse found") +} + +// TestTheStatusBarCountsFailures: a message count says nothing about whether any failed, which +// is the one number worth glancing at. +func TestTheStatusBarCountsFailures(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + status := renderLines(t, model)[0] + assert.NotContains(t, status, "failed", "nothing has failed yet") + + _, cmd := model.Update(tui.PromptSubmitMsg{Line: "read-direct " + plcsession.DemoDeviceOne + " no/such/tag"}) + runCmd(t, model, cmd) + + assert.Equal(t, 1, model.FailedCount()) + assert.Contains(t, renderLines(t, model)[0], "1 failed") +} + +// TestTheDetailPaneDoesNotRepeatItself pins the de-duplication: the header already names the +// operation and the connection, and the log carries the timing line verbatim. +func TestTheDetailPaneDoesNotRepeatItself(t *testing.T) { + model := withMessages(t, "temp/1") + event, ok := model.SelectedEvent() + require.True(t, ok) + + rendered := renderEventDetail(testTheme(), event, 40) + occurrences := strings.Count(rendered, plcsession.DemoDeviceOne) + assert.Equal(t, 1, occurrences, + "the connection should be named once, not in a header and again in a summary:\n%s", rendered) + assert.NotContains(t, rendered, "tags in", + "the timing sentence belongs in the log, not repeated in the detail body") + assert.Contains(t, rendered, "r read", "the detail pane should advertise what can be done with the tag") +} + +// --- aborting, and the quit question --- + +// TestCompletionOffersTagsAsSoonAsTheConnectionIsFollowedByASpace is the reported bug: the tag +// list only appeared once a letter of a tag had been typed. +// +// The cause was that Resolve trimmed the argument text, which destroyed the one thing that +// distinguishes a word still being typed from a word that is finished. +func TestCompletionOffersTagsAsSoonAsTheConnectionIsFollowedByASpace(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + + line := "read-direct " + plcsession.DemoDeviceOne + for _, r := range line { + press(t, model, tea.KeyPressMsg{Code: r, Text: string(r)}) + } + // Still being typed: the connections are what to suggest, and the one open connection is + // already complete, so the only candidate is the line itself. + require.Equal(t, []string{line}, model.PromptCompletions()) + + press(t, model, tea.KeyPressMsg{Code: ' ', Text: " "}) + + completions := model.PromptCompletions() + require.NotEmpty(t, completions, "a space after the connection should offer the tags") + assert.Len(t, completions, len(plcsession.DemoTagAddresses()), + "every tag in the catalogue should be offered") + for _, address := range plcsession.DemoTagAddresses() { + assert.Contains(t, completions, line+" "+address) + } + assert.True(t, model.PromptCompletionOpen(), "and the list should be showing") + + // And tab then takes the first of them, rather than needing a letter typed first. + press(t, model, tea.KeyPressMsg{Code: tea.KeyTab}) + assert.Equal(t, completions[0], model.PromptValue()) +} + +// TestCtrlCAbortsTheCommandRatherThanTheSession is the rule the prompt has always advertised +// while a command runs. It used to be a lie twice over: nothing cancelled the command, and +// ctrl+c ended the session and took every open connection with it. +func TestCtrlCAbortsTheCommandRatherThanTheSession(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + + // Submitting without running the returned command leaves it in flight, which is the state + // a read against an unreachable device sits in for the driver's whole timeout. + _, cmd := model.Update(tui.PromptSubmitMsg{Line: "read-direct " + plcsession.DemoDeviceOne + " temp/1"}) + require.NotNil(t, cmd) + require.True(t, model.PromptBusy(), "the prompt should show the command as running") + + press(t, model, tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + + assert.False(t, model.Quitting(), "ctrl+c during a command must not end the session") + assert.False(t, model.ConfirmingQuit(), "nor ask to") + assert.False(t, model.PromptBusy(), "it releases the prompt") + assert.Contains(t, strings.Join(model.LogLines(), "\n"), "aborted") + + // The outcome is still on its way back. Applying it would contradict the abort, so it is + // dropped: cancelling a context does not stop the goroutine that will deliver the message. + before, _ := model.EventCount() + runCmd(t, model, cmd) + after, _ := model.EventCount() + assert.Equal(t, before, after, "an aborted command's outcome must not be applied") + + // And now that nothing is running, ctrl+c means the session again. + press(t, model, tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + assert.True(t, model.ConfirmingQuit(), "with nothing running ctrl+c asks to quit") +} + +// TestEscAlsoAbortsARunningCommand covers the other half of what the prompt advertises: the +// in-flight indication names esc, not ctrl+c. +func TestEscAlsoAbortsARunningCommand(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + _, cmd := model.Update(tui.PromptSubmitMsg{Line: "read-direct " + plcsession.DemoDeviceOne + " temp/1"}) + require.NotNil(t, cmd) + require.True(t, model.PromptBusy()) + + press(t, model, tea.KeyPressMsg{Code: tea.KeyEscape}) + + assert.False(t, model.PromptBusy(), "esc aborts the command it says it aborts") + assert.False(t, model.Quitting()) +} + +// TestQuitAsksFirstAndOnlyYesAnswersIt is why the question exists: q and ctrl+c are both easy +// to hit by accident, and the answer to a mistaken one should not be a closed session. +func TestQuitAsksFirstAndOnlyYesAnswersIt(t *testing.T) { + for name, answer := range map[string]tea.KeyPressMsg{ + "y": {Code: 'y', Text: "y"}, + "Y": {Code: 'Y', Text: "Y"}, + "enter": {Code: tea.KeyEnter}, + "another ctrl": {Code: 'c', Mod: tea.ModCtrl}, + } { + model := sized(t, newTestModel(t), 120, 30) + press(t, model, tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + require.True(t, model.ConfirmingQuit(), name) + require.False(t, model.Quitting(), "%s: asking is not quitting", name) + + press(t, model, answer) + assert.True(t, model.Quitting(), "%s should answer the question", name) + } +} + +// TestAnyOtherKeyKeepsTheSession is the point of asking. The refusing key is swallowed rather +// than passed on, so it cannot both dismiss the question and do something unseen. +func TestAnyOtherKeyKeepsTheSession(t *testing.T) { + for name, refusal := range map[string]tea.KeyPressMsg{ + "esc": {Code: tea.KeyEscape}, + "n": {Code: 'n', Text: "n"}, + "slash": {Code: '/', Text: "/"}, + "digit": {Code: '2', Text: "2"}, + } { + model := sized(t, newTestModel(t), 120, 30) + press(t, model, tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + require.True(t, model.ConfirmingQuit(), name) + + press(t, model, refusal) + + assert.False(t, model.Quitting(), "%s must not quit", name) + assert.False(t, model.ConfirmingQuit(), "%s takes the question down", name) + assert.Empty(t, model.PromptValue(), "%s must not also reach the text field", name) + assert.True(t, mustPromptFocused(model), "%s must not also move the keyboard", name) + } +} + +// TestTheQuitQuestionIsOnScreen matters because a modal state that swallows every key has to +// say so: the row above the prompt asks, and the footer says what answers it. +func TestTheQuitQuestionIsOnScreen(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + press(t, model, tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + require.True(t, model.ConfirmingQuit()) + + screen := model.render() + assert.Contains(t, screen, "quit?") + assert.Contains(t, screen, "stay", "the footer has to say how to refuse") + assert.NotContains(t, screen, "tab complete", + "and must not advertise bindings that are inert until the question is answered") +} + +// --- the Detail pane's own hints --- + +// TestEveryDetailHintKeyWorksAtDetailFocus is the guard the footer's equivalent could not give. +// +// The Detail pane draws its own action line, and r, w and s were bound only at Messages focus: +// the pane advertised three keys that did nothing in the pane doing the advertising, which is +// the dead-affordance defect this port existed to remove. The test walks exactly what the pane +// draws, so a key cannot be advertised there without being handled there. +func TestEveryDetailHintKeyWorksAtDetailFocus(t *testing.T) { + require.NotEmpty(t, detailActions, "the hint line has to advertise something") + + for _, action := range detailActions { + model := browsed(t) + focusDetail(t, model) + require.Contains(t, tuitest.Strip(model.render()), action.Key+" "+action.Label, + "the pane should be advertising %q", action.Key) + + // A key may act on the screen or produce a command: the compose keys open a form, the + // byte view redraws the pane, and yank only hands the clipboard a command. + before := model.render() + _, cmd := model.Update(charKeyPress(action.Key)) + assert.True(t, model.render() != before || cmd != nil, + "%s (%s) does nothing at detail focus", action.Key, action.Label) + } +} + +// TestActingOnABrowseFromTheDetailPane is the workflow the hint promises: browse, read the tag +// list in Detail, and act on it from there without retyping an address. +func TestActingOnABrowseFromTheDetailPane(t *testing.T) { + for key, operation := range map[string]Operation{ + "r": OperationRead, + "w": OperationWrite, + "s": OperationSubscribe, + } { + model := browsed(t) + focusDetail(t, model) + + press(t, model, charKeyPress(key)) + require.True(t, model.ComposerOpen(), "%s should open the composer from the detail pane", key) + + spec := model.ComposerSpec() + assert.Equal(t, operation, spec.Operation, "%s should compose a %s", key, operation) + assert.Equal(t, plcsession.DemoDeviceOne, spec.Connection, + "%s should carry the browsed connection", key) + require.Len(t, spec.Tags, len(plcsession.DemoTagAddresses()), + "%s should carry every tag the browse found", key) + addresses := make([]string, 0, len(spec.Tags)) + for _, tag := range spec.Tags { + addresses = append(addresses, tag.Address) + } + assert.ElementsMatch(t, plcsession.DemoTagAddresses(), addresses, + "%s should not have to be told the addresses again", key) + } +} + +// TestAReadComposedFromTheDetailPaneActuallyRuns closes the loop: the composer the hint opens +// has to produce a request, not just a form. +func TestAReadComposedFromTheDetailPaneActuallyRuns(t *testing.T) { + model := browsed(t) + before, _ := model.EventCount() + + focusDetail(t, model) + press(t, model, charKeyPress("r")) + require.True(t, model.ComposerOpen()) + + _, cmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + require.NotNil(t, cmd, "enter should run the composed request") + runCmd(t, model, cmd) + + after, _ := model.EventCount() + assert.Greater(t, after, before, "the composed read has to reach the message list") + assert.False(t, model.ComposerOpen(), "and the form closes once it has done its job") + assert.Empty(t, model.Toast(), "with no error held") +} + +// TestYankFromTheDetailPaneCopiesWhatItShows covers the fourth hint, which is the one that +// produces a command rather than a form. +func TestYankFromTheDetailPaneCopiesWhatItShows(t *testing.T) { + model := browsed(t) + focusDetail(t, model) + + _, cmd := model.Update(charKeyPress("y")) + require.NotNil(t, cmd, "y should produce a clipboard command") + + // bubbletea's clipboard message is unexported, so what it carries is read as text rather + // than type-asserted. The OSC52 payload is the whole point of the assertion either way. + copied := fmt.Sprint(cmd()) + for _, address := range plcsession.DemoTagAddresses() { + assert.Contains(t, copied, address, "the yank should carry every tag shown") + } +} + +// browsed returns a model with one browse in its history, which is what puts a tag list in the +// Detail pane for the hint keys to act on. +// +// Tall enough that the pane can show the whole catalogue and its hint line beneath: the hint +// sits below the tags, so at a shorter height it is simply scrolled out of view. +func browsed(t *testing.T) *Model { + t.Helper() + model := sized(t, newTestModel(t), 120, 44) + _, cmd := model.Update(tui.PromptSubmitMsg{Line: "browse-direct " + plcsession.DemoDeviceOne}) + require.NotNil(t, cmd) + runCmd(t, model, cmd) + return model +} + +// focusDetail moves the keyboard to the Detail pane by its number, the way a user does. +func focusDetail(t *testing.T, model *Model) { + t.Helper() + press(t, model, charKeyPress("3")) + require.Equal(t, "detail", model.FocusName(), "pane 3 should be the detail pane") +} + +// charKeyPress builds the key press a terminal sends for a printable character. +func charKeyPress(s string) tea.KeyPressMsg { + return tea.KeyPressMsg{Code: rune(s[0]), Text: s} +} + +// TestEveryPaneUsesItsFullHeight is the geometry the side-by-side layout implies. +// +// The sizing code used to halve the body height for the messages and hand the remainder to the +// detail, which is the geometry of a layout that stacked the two. No layout does. Both panes +// drew a full-height box and then filled the lower half with blank rows, and content past the +// halfway mark was clipped -- a browse of eight tags lost the action hint drawn beneath them. +func TestEveryPaneUsesItsFullHeight(t *testing.T) { + for _, size := range [][2]int{{157, 50}, {120, 44}, {120, 30}, {100, 24}} { + model := sized(t, newTestModel(t), size[0], size[1]) + want := model.layout.BodyHeight - 2 + require.Positive(t, want, "%dx%d should have a body", size[0], size[1]) + + // The table spends one of its rows on the column header, so the rows it offers for + // messages are one fewer than the pane interior. The viewport has no header. + assert.Equal(t, want-1, model.messages.Height(), + "the message table should use the whole pane at %dx%d", size[0], size[1]) + assert.Equal(t, want, model.detail.Height(), + "the detail viewport should use the whole pane at %dx%d", size[0], size[1]) + + // Whatever the arithmetic, neither may be sized to about half the pane, which is what + // the stacked-layout geometry produced. + assert.Greater(t, model.messages.Height(), want*2/3, + "the table should not be sized as if something sat beneath it") + assert.Greater(t, model.detail.Height(), want*2/3, + "nor the detail as if something sat above it") + } +} + +// TestAllTheTagsAndTheHintFitInTheDetailPane is the behavioural half of the same fix: what the +// pane has room to show, it shows. +func TestAllTheTagsAndTheHintFitInTheDetailPane(t *testing.T) { + model := browsed(t) + focusDetail(t, model) + screen := tuitest.Strip(model.render()) + + for _, address := range plcsession.DemoTagAddresses() { + assert.Contains(t, screen, address, "every browsed tag should be on screen") + } + for _, action := range detailActions { + assert.Contains(t, screen, action.Key+" "+action.Label, + "the hint beneath them should be too") + } +} + +// TestTheDetailHintNeverLosesAnAction covers the wrap itself. The hint is one line where it +// fits and several where it does not, and at eighty columns it used to lose its last action +// half way through a word -- "y yan". A truncated advertisement is worse than a second row. +func TestTheDetailHintNeverLosesAnAction(t *testing.T) { + theme := testTheme() + for width := 8; width <= 60; width++ { + lines := detailHint(theme, width) + require.NotEmpty(t, lines, "width %d should still advertise something", width) + + joined := strings.Join(lines, "\n") + for _, action := range detailActions { + assert.Contains(t, joined, action.Key+" "+action.Label, + "width %d dropped or cut %q", width, action.Key) + } + // And no line may overflow the pane, since the pane clips rather than wraps. + for _, line := range lines { + if lipgloss.Width(line) > width { + // One action on its own is allowed to exceed a very narrow pane: there is + // nothing left to break, and clipping "s subscribe" still reads as the key. + assert.Len(t, strings.Split(tuitest.Strip(line), " "+theme.Glyphs.Separator+" "), 1, + "width %d line %q overflows and could have been broken", width, line) + } + } + } +} diff --git a/plc4go/tools/plc4xbrowser/browser/model.go b/plc4go/tools/plc4xbrowser/browser/model.go new file mode 100644 index 000000000..93b6945b2 --- /dev/null +++ b/plc4go/tools/plc4xbrowser/browser/model.go @@ -0,0 +1,1503 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package browser + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/table" + "charm.land/bubbles/v2/viewport" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/rs/zerolog" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/plcsession" + "github.com/apache/plc4x-extras/plc4go/tools/internal/tui" +) + +// Model is the browser's root Bubble Tea model. +// +// Every piece of state lives here and is mutated only inside Update. Nothing else may touch +// it: the implementation this replaces mutated tview widgets from goroutines through +// QueueUpdateDraw, which is precisely the pattern the Elm architecture forbids and precisely +// why none of it could be tested. + +// pane identifies a focusable region. +type pane int + +const ( + paneSidebar pane = iota + paneMessages + paneDetail + paneLog + + paneCount +) + +// title names a pane for the status bar and its border. +func (p pane) title() string { + switch p { + case paneSidebar: + return "Session" + case paneMessages: + return "Messages" + case paneDetail: + return "Detail" + case paneLog: + return "Log" + default: + return "?" + } +} + +// sidebarRow is one line of the sidebar. Sections and items share a row list so that a single +// selection index walks the whole pane: the design review flagged two independent cursors in +// one focus target as a defect, and one flat list is the fix. +type sidebarRow struct { + // heading marks a section title, which cannot be selected. + heading string + // label is the row text for a selectable row. + label string + // detail is dimmer trailing text, such as a driver's name. + detail string + // marker is a status glyph. + marker string + // connection and driver name what the row acts on when it is chosen. + connection string + driver string +} + +// selectable reports whether the cursor may rest on this row. +func (r sidebarRow) selectable() bool { return r.heading == "" } + +// Options configures a Model. +type Options struct { + // Session is the PLC seam: plcsession.Live normally, plcsession.Demo under --demo. + Session plcsession.Session + // Config is the persisted settings. + Config *Config + // Demo records that the session is simulated, so the UI can say so. + Demo bool + // Version labels the status bar. + Version string + // Theme overrides the theme; when nil one is derived from the environment and the + // terminal's reported background. + Theme *tui.Theme + // ForceASCII pins the ASCII glyph set regardless of the locale. + ForceASCII bool + // Frames, when set, is the wire capture the detail pane's byte view reads. Demo mode leaves + // it nil: a simulated device puts nothing on a wire, and inventing bytes for it would be + // worse than saying there are none. + Frames *plcsession.FrameLog + // Now supplies timestamps, injected for tests. + Now func() time.Time +} + +// Model is the root model. +type Model struct { + options Options + theme tui.Theme + keys tui.KeyMap + layout tui.Layout + registry *Registry + env *Env + + focus pane + // promptFocused is tracked separately from focus because the prompt is not a pane: it is + // always visible, and the pane focus ring is what tab walks. + promptFocused bool + + prompt tui.Prompt + help tui.HelpFooter + + messages table.Model + detail viewport.Model + // detailBytes switches the detail pane to the wire bytes of the selected request. + detailBytes bool + // follow keeps the newest message selected as events arrive. On by default, because a tool + // watching a subscription is usually watching the latest value; it turns itself off the + // moment the cursor is moved by hand. + follow bool + // filtering is true while the filter line is being typed, which claims the keyboard. + filtering bool + // filterDraft is the filter being typed, applied on enter. + filterDraft string + // toastExpanded shows the whole of a long error instead of one clipped line. + toastExpanded bool + // yanked records what was last copied, so the interface can confirm it. + yanked string + // catalogue is the tags the last browse found, kept as a reference list. Deliberately not + // "the selected message's tags": mirroring the detail pane in the sidebar would show the + // same thing twice, whereas a catalogue that persists is what you want beside the message + // list once you have moved on to reading individual tags. + catalogue []plcsession.TagResult + catalogueConnection string + + logView viewport.Model + composer *composer + + // running cancels the command in flight, and runSeq identifies it. A late outcome from an + // aborted command has to be dropped rather than applied: cancelling a context does not + // stop the goroutine that will eventually deliver the message, so without the sequence the + // abort would be followed by the aborted command's own error appearing anyway. + running context.CancelFunc + runSeq int + + // confirmQuit holds the "really quit?" question. ctrl+c used to kill the tool outright, + // which is the wrong default for a session holding open connections: the same keystroke is + // how a user stops a command that is taking too long. + confirmQuit bool + sidebarRows []sidebarRow + sidebarIndex int + + // events backs the message table. The filter is applied when the rows are built. + events []plcsession.Event + filter string + logLines []string + + // toast is the last error, held until dismissed. The old UI let errors scroll away in a + // ten-row console, so a failed command could vanish before it was read. + toast string + + streams []*Stream + quit bool + ready bool +} + +// NewModel builds the root model. +func NewModel(options Options) *Model { + if options.Now == nil { + options.Now = time.Now + } + if options.Config == nil { + config := NewConfig() + options.Config = &config + } + + theme := tui.NewTheme(themeOptions(true, options)) + if options.Theme != nil { + theme = *options.Theme + } + + registry := NewCommands() + model := &Model{ + options: options, + theme: theme, + keys: tui.NewKeyMap(), + registry: registry, + focus: paneMessages, + } + model.env = &Env{ + Session: options.Session, + Config: options.Config, + Now: options.Now, + Registry: registry, + Demo: options.Demo, + StreamContext: func() (context.Context, context.CancelFunc) { + return context.WithCancel(context.Background()) + }, + } + + model.prompt = tui.NewPrompt(theme, func(line string) []string { + return registry.Complete(model.env, line) + }) + model.prompt.SetClock(options.Now) + model.help = tui.NewHelpFooter(theme) + + model.messages = table.New( + table.WithColumns(messageColumns(80, false)), + table.WithFocused(false), + table.WithStyles(tableStyles(theme)), + ) + model.detail = viewport.New() + model.logView = viewport.New() + model.promptFocused = true + model.follow = true + model.refreshSidebar() + return model +} + +// themeOptions resolves the theme options for a background brightness. +func themeOptions(dark bool, options Options) tui.Options { + themeOptions := tui.OptionsFromEnv(dark) + if options.ForceASCII { + themeOptions.ASCII = true + } + return themeOptions +} + +// messageColumns sizes the message table to exactly the width it is given. +// +// The previous fixed widths claimed 48 cells, which is eight more than the pane has at the +// tool's own reference size of 100 columns. The table then truncated, and what it truncated was +// the tag -- the one field the row exists to show -- while the connection column repeated the +// same string on every line. Columns are therefore budgeted in order of what a row is for: the +// tag and its outcome first, the connection last, since with one connection open it says +// nothing at all. +func messageColumns(width int, showConnection bool) []table.Column { + width = max(width, 24) + + // Columns are dropped, not squeezed. Squeezing is what the fixed widths did: they claimed + // 48 cells in the 40-cell pane the reference layout gives, and the table then truncated the + // tag -- the one field a row exists to show. So the budget is spent in priority order and + // whatever does not fit is left out entirely, which is legible where a two-character column + // is not. + const tagFloor = 8 + + stamp, operation, code := 13, 10, 7 + if width < 70 { + // To the second is enough to place an event; the millisecond is in the detail pane. + stamp, operation, code = 9, 7, 7 + } + + // number and connection are the two the tag outranks. + number := 4 + connection := 0 + if showConnection && width >= 4+stamp+operation+code+tagFloor+16 { + connection = 16 + } + + remaining := func() int { return width - number - stamp - operation - code - connection } + if remaining() < tagFloor { + connection = 0 + } + if remaining() < tagFloor { + // The row number is positional and repeated in the detail pane's ordering, so it is the + // next thing the tag can have. + number = 0 + } + if remaining() < tagFloor { + code = 4 // "OK" or "bad" + } + if remaining() < tagFloor { + operation = 5 // "read", "brow", "subs" + } + if remaining() < tagFloor { + stamp = 8 // HH:MM:SS + } + if remaining() < tagFloor { + // At the very floor -- a pane in a 60-column terminal -- even the operation goes. The + // detail pane names it, and a tag clipped to five characters names nothing. + operation = 0 + } + + var columns []table.Column + if number > 0 { + columns = append(columns, table.Column{Title: "#", Width: number}) + } + columns = append(columns, table.Column{Title: "time", Width: stamp}) + if operation > 0 { + columns = append(columns, table.Column{Title: "op", Width: operation}) + } + if connection > 0 { + columns = append(columns, table.Column{Title: "connection", Width: connection}) + } + return append(columns, + table.Column{Title: "tag", Width: max(remaining(), 1)}, + table.Column{Title: "code", Width: code}, + ) +} + +// showConnectionColumn reports whether the connection is worth a column: only once more than +// one is open, since otherwise every row would carry the same string. +func (m *Model) showConnectionColumn() bool { + return len(m.options.Session.Connections()) > 1 +} + +// tableStyles maps the theme onto the table's own styling. +// +// bubbles/table defaults to a palette of its own, so without this a colourless theme still +// emitted a 256-colour selected row -- which breaks NO_COLOR and makes golden output unstable. +func tableStyles(theme tui.Theme) table.Styles { + styles := table.DefaultStyles() + styles.Header = theme.Key.Bold(true) + // Cell is deliberately bare rather than theme.Value. bubbles/table styles each cell and + // then styles the selected row around them, and a cell's own styling ends with a reset -- + // so any foreground here wipes the selection from every cell after the first and leaves + // the selected row indistinguishable. Body text wants the terminal's default colour + // anyway, which is what a bare style renders as. + styles.Cell = lipgloss.NewStyle() + styles.Selected = theme.SelectedRow + if theme.IsNoColor() { + // Reverse video would be the obvious colourless highlight, but it fights the selection + // glyph and hurts readability; bold alone is enough. + styles.Header = theme.Key.Bold(true) + styles.Selected = theme.Value.Bold(true) + } + return styles +} + +// commandDoneMsg carries the outcome of a command back into Update. +type commandDoneMsg struct { + line string + result Result + err error + // seq identifies the run, so that an aborted command's outcome can be recognised and + // dropped when it arrives after the fact. + seq int +} + +// streamEventMsg is one subscription event, plus the stream it came from so the reader can +// re-arm on the same channel. +type streamEventMsg struct { + stream *Stream + event plcsession.Event + open bool +} + +// Init starts the prompt cursor and its spinner ticker. +func (m *Model) Init() tea.Cmd { + return tea.Batch(m.prompt.Focus(), tui.PromptTick()) +} + +// Update handles one message. +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.resize(tui.Size{Width: msg.Width, Height: msg.Height}) + return m, nil + + case tea.BackgroundColorMsg: + // The terminal reports its background at startup and whenever it changes, which is the + // only reliable way to pick a palette that stays legible. + if m.options.Theme == nil { + m.theme = tui.NewTheme(themeOptions(msg.IsDark(), m.options)) + m.help = tui.NewHelpFooter(m.theme) + m.messages.SetStyles(tableStyles(m.theme)) + m.refreshSidebar() + } + return m, nil + + case tea.KeyPressMsg: + return m.handleKey(msg) + + case tea.MouseMsg: + return m.handleMouse(msg) + + case tui.PromptSubmitMsg: + return m.submit(msg.Line) + + case commandDoneMsg: + return m.applyCommand(msg) + + case streamEventMsg: + return m.applyStreamEvent(msg) + } + + var cmd tea.Cmd + m.prompt, cmd = m.prompt.Update(msg) + return m, cmd +} + +// handleKey routes a key press according to what currently has focus. +func (m *Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + // The composer is modal: while it is open it owns the keyboard, apart from quit. + if m.composer != nil { + if key.Matches(msg, m.keys.ForFocus(tui.FocusOverlay).Quit) { + return m.shutdown() + } + return m.updateComposer(msg) + } + + // The quit question is modal and answered before anything else, including the filter line + // and the prompt: a key that reached the text field while it was up would leave the + // question on screen with no way to tell what answered it. + if m.confirmQuit { + return m.answerQuit(msg) + } + + // The filter line is text entry, so it takes every key but the ones that finish it. + if m.filtering { + return m.filterKey(msg) + } + + keys := m.keys.ForFocus(m.currentFocus()) + + switch { + case key.Matches(msg, keys.Quit): + // ctrl+c stops what is happening rather than ending the session. A user whose read is + // hanging on an unreachable device reaches for it to get the prompt back, and losing + // every open connection instead is not a reasonable answer to that keystroke. With + // nothing running it asks, because q and ctrl+c are both easy to hit by accident. + if m.abortRunning() { + return m, nil + } + m.confirmQuit = true + return m, nil + + case key.Matches(msg, keys.EOF): + // The shell rule: ctrl+d ends the session on an empty line, and is left to the text + // input to delete a character otherwise. bubbles/textinput binds it to delete-forward, + // so quitting unconditionally would take that away. + if !m.promptFocused || m.prompt.Value() == "" { + m.confirmQuit = true + return m, nil + } + + case key.Matches(msg, keys.Help): + m.help.SetShowAll(!m.help.ShowAll()) + return m, nil + + case key.Matches(msg, keys.Cancel): + // Escape unwinds one level at a time. A command in flight is the outermost level, + // because the prompt advertises "esc cancel" for exactly as long as one is running. + if m.abortRunning() { + return m, nil + } + // Then the prompt's own use of it, then the toast, then a focused pane back to the + // prompt. + if m.promptFocused && m.prompt.ConsumesEscape() { + break + } + if m.toast != "" { + m.toast = "" + return m, nil + } + if !m.promptFocused { + m.focusPrompt() + return m, nil + } + + case key.Matches(msg, keys.PaneJump), m.promptFocused && m.prompt.Value() == "" && isPaneDigit(msg): + // Direct pane hotkeys. The pane numbers are drawn in the pane titles, so the mapping is + // visible rather than something to remember. + // + // alt+1..alt+4 is the nominal binding, but it cannot be the only one: GNOME Terminal, + // Konsole and Windows Terminal all bind alt+digit to switching terminal tabs and never + // deliver it to the application. A bare digit therefore works too whenever it cannot be + // text -- inside a pane, or at a prompt with nothing typed yet. No command begins with a + // digit, so nothing is shadowed. + if target, ok := paneForDigit(msg.String()); ok { + m.blurPrompt() + m.setFocus(target) + } + return m, nil + + case key.Matches(msg, keys.NextPane): + m.cyclePane(1) + return m, nil + + case key.Matches(msg, keys.PrevPane): + m.cyclePane(-1) + return m, nil + + case key.Matches(msg, keys.FocusPrompt): + m.focusPrompt() + return m, nil + + case key.Matches(msg, keys.FocusPanes): + // From the prompt, tab enters the pane ring only when there is nothing to complete; + // otherwise tab is completion, which is what a REPL user expects. + if m.promptFocused && m.prompt.CompletionOpen() { + break + } + if m.promptFocused { + m.blurPrompt() + m.setFocus(paneSidebar) + return m, nil + } + } + + if m.promptFocused { + var cmd tea.Cmd + m.prompt, cmd = m.prompt.Update(msg) + return m, cmd + } + return m.paneKey(msg, keys) +} + +// isPaneDigit reports whether a key press is a bare digit naming a pane. +func isPaneDigit(msg tea.KeyPressMsg) bool { + name := msg.String() + if len(name) != 1 { + return false + } + _, ok := paneForDigit(name) + return ok +} + +// paneForDigit maps a pane-jump key onto a pane. The digit is the last character, so it works +// for the bare "2" and for "alt+2" alike. +func paneForDigit(name string) (pane, bool) { + if name == "" { + return 0, false + } + digit, err := strconv.Atoi(name[len(name)-1:]) + if err != nil || digit < 1 || digit > int(paneCount) { + return 0, false + } + return pane(digit - 1), true +} + +// filterKey handles a key while the message filter is being typed. +func (m *Model) filterKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + // Escape abandons the edit and clears the filter, which is the only way back to the + // whole list that does not require deleting what was typed. + m.filtering = false + m.filterDraft = "" + m.SetFilter("") + return m, nil + case "enter": + m.filtering = false + m.SetFilter(m.filterDraft) + return m, nil + case "backspace": + if m.filterDraft != "" { + runes := []rune(m.filterDraft) + m.filterDraft = string(runes[:len(runes)-1]) + m.SetFilter(m.filterDraft) + } + return m, nil + } + if text := msg.String(); len([]rune(text)) == 1 { + m.filterDraft += text + // Applied as it is typed: seeing the list narrow is the whole point of a filter. + m.SetFilter(m.filterDraft) + } + return m, nil +} + +// Filtering reports whether the filter line is being typed, for tests. +func (m *Model) Filtering() bool { return m.filtering } + +// Following reports whether the newest message stays selected, for tests. +func (m *Model) Following() bool { return m.follow } + +// currentFocus maps the model's focus onto the keymap's notion of it. +func (m *Model) currentFocus() tui.Focus { + switch { + case m.composer != nil: + return tui.FocusOverlay + case m.promptFocused: + return tui.FocusPrompt + default: + return tui.FocusPane + } +} + +// paneKey handles a key aimed at the focused pane. +// +// Every binding the help advertises is handled here. That is not a nicety: the shared keymap +// puts g/G, the page keys, the filter, yank, follow, expand and the log level in the footer, +// and for a while the browser advertised all seven and implemented none of them -- the same +// dead-affordance defect the port existed to remove, reintroduced in the help text. +func (m *Model) paneKey(msg tea.KeyPressMsg, keys tui.KeyMap) (tea.Model, tea.Cmd) { + // Bindings that mean the same thing in every pane. + switch { + case key.Matches(msg, keys.Top): + return m, m.paneTop() + case key.Matches(msg, keys.Bottom): + return m, m.paneBottom() + case key.Matches(msg, keys.PageUp): + return m, m.panePage(-1) + case key.Matches(msg, keys.PageDown): + return m, m.panePage(1) + case key.Matches(msg, keys.Yank): + return m.yank() + case key.Matches(msg, keys.Expand): + m.toastExpanded = !m.toastExpanded + return m, nil + case key.Matches(msg, keys.LogLevel): + return m.cycleLogLevel() + } + + // Composing a request from the selected message's tag: browse, see a tag, act on it. + // Retyping the address the pane is already showing was the sharpest edge in the tool. + // + // Both panes that show that message, not just the one holding the selection. Detail is + // where the tag is actually read, and it is Detail that draws the "r read / w write / + // s subscribe" hint -- so binding these to Messages alone put the advertisement in the one + // pane where the keys did nothing. The sidebar is left out: its rows are connections, + // drivers and tag names rather than a request, so acting on a message from there would be + // acting on something the pane is not showing. + if m.focus == paneMessages || m.focus == paneDetail { + if spec, ok := m.composeFromSelection(msg.String()); ok { + m.openComposer(spec) + return m, nil + } + } + + switch m.focus { + case paneSidebar: + switch { + case key.Matches(msg, keys.Up): + m.moveSidebar(-1) + return m, nil + case key.Matches(msg, keys.Down): + m.moveSidebar(1) + return m, nil + case key.Matches(msg, keys.Select): + return m.activateSidebarRow() + } + case paneMessages: + switch { + case key.Matches(msg, keys.Filter): + m.filtering = true + m.filterDraft = m.filter + return m, nil + case key.Matches(msg, keys.Follow): + m.follow = !m.follow + if m.follow { + m.messages.GotoBottom() + m.syncDetail() + } + return m, nil + case key.Matches(msg, keys.Select): + m.syncDetail() + return m, nil + } + var cmd tea.Cmd + m.messages, cmd = m.messages.Update(msg) + // Moving the cursor by hand means the user has taken over from the stream. + if key.Matches(msg, keys.Up) || key.Matches(msg, keys.Down) { + m.follow = false + } + m.syncDetail() + return m, cmd + case paneDetail: + if key.Matches(msg, keys.Wrap) { + m.detail.SoftWrap = !m.detail.SoftWrap + return m, nil + } + if msg.String() == "b" { + // The same key the pcap analyzer uses for its byte view, so the two tools agree. + m.detailBytes = !m.detailBytes + m.syncDetail() + return m, nil + } + var cmd tea.Cmd + m.detail, cmd = m.detail.Update(msg) + return m, cmd + case paneLog: + var cmd tea.Cmd + m.logView, cmd = m.logView.Update(msg) + return m, cmd + } + return m, nil +} + +// paneTop jumps the focused pane to its start. +func (m *Model) paneTop() tea.Cmd { + switch m.focus { + case paneSidebar: + m.sidebarIndex = 0 + m.clampSidebar() + case paneMessages: + m.messages.GotoTop() + m.follow = false + m.syncDetail() + case paneDetail: + m.detail.GotoTop() + case paneLog: + m.logView.GotoTop() + } + return nil +} + +// paneBottom jumps the focused pane to its end. +func (m *Model) paneBottom() tea.Cmd { + switch m.focus { + case paneSidebar: + m.sidebarIndex = len(m.sidebarRows) - 1 + m.clampSidebar() + case paneMessages: + m.messages.GotoBottom() + m.syncDetail() + case paneDetail: + m.detail.GotoBottom() + case paneLog: + m.logView.GotoBottom() + } + return nil +} + +// panePage scrolls the focused pane by a screenful in a direction. +func (m *Model) panePage(direction int) tea.Cmd { + switch m.focus { + case paneSidebar: + for range max(m.layout.BodyHeight-2, 1) { + m.moveSidebar(direction) + } + case paneMessages: + page := max(m.messages.Height(), 1) + if direction < 0 { + m.messages.MoveUp(page) + } else { + m.messages.MoveDown(page) + } + m.follow = false + m.syncDetail() + case paneDetail: + m.detail.SetYOffset(max(m.detail.YOffset()+direction*max(m.detail.Height(), 1), 0)) + case paneLog: + m.logView.SetYOffset(max(m.logView.YOffset()+direction*max(m.logView.Height(), 1), 0)) + } + return nil +} + +// yank copies what the focused pane is showing to the clipboard. +// +// tea.SetClipboard writes an OSC52 sequence, which the terminal forwards to the local +// clipboard, so this works over ssh as well as locally. +func (m *Model) yank() (tea.Model, tea.Cmd) { + text := "" + switch m.focus { + case paneSidebar: + if row, ok := m.SidebarSelection(); ok { + text = row.label + } + case paneMessages, paneDetail: + if event, ok := m.SelectedEvent(); ok { + text = yankText(event) + } + case paneLog: + text = strings.Join(m.logLines, "\n") + } + if text == "" { + return m, nil + } + m.yanked = text + m.appendLog("copied " + strconv.Itoa(len(text)) + " bytes to the clipboard") + return m, tea.SetClipboard(text) +} + +// yankText renders an event as the plain text a user would want on the clipboard: the values, +// not the styling. +func yankText(event plcsession.Event) string { + var parts []string + parts = append(parts, string(event.Kind)+" "+event.Connection) + for _, tag := range event.Tags { + line := tag.Address + if tag.DataType != "" { + line += " " + tag.DataType + } + if tag.Value != "" { + line += " " + tag.Value + } + if !tag.Succeeded() { + line += " " + tag.Code + } + parts = append(parts, line) + } + return strings.Join(parts, "\n") +} + +// cycleLogLevel steps the log level, so the noise can be turned up without leaving the pane. +func (m *Model) cycleLogLevel() (tea.Model, tea.Cmd) { + order := []zerolog.Level{ + zerolog.ErrorLevel, zerolog.WarnLevel, zerolog.InfoLevel, zerolog.DebugLevel, zerolog.TraceLevel, + } + current, err := zerolog.ParseLevel(m.options.Config.LogLevel) + if err != nil { + current = zerolog.InfoLevel + } + next := order[0] + for i, level := range order { + if level == current { + next = order[(i+1)%len(order)] + break + } + } + m.options.Config.LogLevel = next.String() + zerolog.SetGlobalLevel(next) + m.appendLog("log level " + next.String()) + return m, nil +} + +// composeFromSelection turns r, w or s pressed over a message into a pre-filled request for +// the tag that message is showing. +func (m *Model) composeFromSelection(pressed string) (ComposeSpec, bool) { + var operation Operation + switch pressed { + case "r": + operation = OperationRead + case "w": + operation = OperationWrite + case "s": + operation = OperationSubscribe + default: + return ComposeSpec{}, false + } + + event, ok := m.SelectedEvent() + if !ok || len(event.Tags) == 0 { + return ComposeSpec{}, false + } + tags := make([]plcsession.TagSpec, 0, len(event.Tags)) + for i, tag := range event.Tags { + if tag.Address == "" { + continue + } + tags = append(tags, plcsession.TagSpec{Name: "tag" + strconv.Itoa(i+1), Address: tag.Address}) + } + if len(tags) == 0 { + return ComposeSpec{}, false + } + return ComposeSpec{Operation: operation, Connection: event.Connection, Tags: tags}, true +} + +// focusPrompt returns the keyboard to the command line. +func (m *Model) focusPrompt() { + m.promptFocused = true + m.messages.Blur() + _ = m.prompt.Focus() +} + +// blurPrompt moves the keyboard into the pane ring. +func (m *Model) blurPrompt() { + m.promptFocused = false + m.prompt.Blur() +} + +// cyclePane walks the focus ring, returning to the prompt past either end so that no sequence +// of tabs can strand the user away from the command line. +func (m *Model) cyclePane(delta int) { + if m.promptFocused { + m.blurPrompt() + if delta < 0 { + m.setFocus(paneLog) + } else { + m.setFocus(paneSidebar) + } + return + } + next := int(m.focus) + delta + if next < 0 || next >= int(paneCount) { + m.focusPrompt() + return + } + m.setFocus(pane(next)) +} + +// setFocus moves focus to a pane, keeping the table's own focus flag in step. +func (m *Model) setFocus(p pane) { + m.focus = p + if p == paneMessages { + m.messages.Focus() + } else { + m.messages.Blur() + } +} + +// Focused reports the focused pane and whether the prompt holds the keyboard, for tests. +func (m *Model) Focused() (pane, bool) { return m.focus, m.promptFocused } + +// shutdown cancels every subscription and saves the configuration before quitting. +func (m *Model) shutdown() (tea.Model, tea.Cmd) { + m.quit = true + for _, stream := range m.streams { + stream.Cancel() + } + m.streams = nil + _ = m.options.Session.Close() + if path, err := ConfigPath(); err == nil { + _ = m.options.Config.Save(path) + } + return m, tea.Quit +} + +// answerQuit resolves the quit question. +// +// Only an explicit yes ends the session. Anything else takes the question down and is swallowed +// rather than passed on, so a stray keystroke cannot both dismiss the question and do something +// else the user did not see coming. +func (m *Model) answerQuit(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + keys := m.keys.ForFocus(m.currentFocus()) + switch { + case key.Matches(msg, keys.Quit), key.Matches(msg, keys.Submit): + // ctrl+c again confirms, which is the pattern every shell has taught. + return m.shutdown() + case msg.String() == "y", msg.String() == "Y": + return m.shutdown() + } + m.confirmQuit = false + return m, nil +} + +// submit runs the line the prompt handed over. +func (m *Model) submit(line string) (tea.Model, tea.Cmd) { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + return m, nil + } + m.options.Config.AddCommand(trimmed) + m.appendLog("$ " + trimmed) + m.prompt.Start() + return m, m.runCommand(trimmed) +} + +// abortRunning cancels the command in flight and reports whether there was one. +// +// The sequence is bumped so that the outcome already on its way back is ignored. The prompt is +// released here rather than when that outcome arrives, because the point of an abort is that +// the user gets the prompt back now. +func (m *Model) abortRunning() bool { + if m.running == nil { + return false + } + m.running() + m.running = nil + m.runSeq++ + m.prompt.Finish() + if m.composer != nil && m.composer.submitting { + m.composer.submitting = false + m.composer.problem = "aborted" + } + m.appendLogAt("warn", "aborted") + return true +} + +// runCommand executes a line off the event loop and reports the outcome as a message. +// +// This is the shape every piece of I/O in the model takes: a tea.Cmd does the work and a typed +// message carries the result back, so that nothing but Update ever writes state. +func (m *Model) runCommand(line string) tea.Cmd { + env := m.env + registry := m.registry + // Cancellable, because the prompt says "esc cancel" while a command runs and that has to + // be true. A read against an unreachable device blocks for the driver's whole timeout. + ctx, cancel := context.WithCancel(context.Background()) + m.running = cancel + seq := m.runSeq + return func() tea.Msg { + result, err := registry.Execute(ctx, env, line) + return commandDoneMsg{line: line, result: result, err: err, seq: seq} + } +} + +// applyCommand folds a command's outcome into the model. +func (m *Model) applyCommand(msg commandDoneMsg) (tea.Model, tea.Cmd) { + if msg.seq != m.runSeq { + // The outcome of a command the user aborted. It has already been reported as aborted, + // and applying it now would contradict that. + return m, nil + } + m.running = nil + m.prompt.Finish() + + // A form marked submitting is waiting for exactly this message, so it is the composer's + // answer rather than the prompt's. On success the form has done its job and closes; on + // failure it stays open carrying the reason, so the request can be corrected instead of + // retyped. Without this the form stayed open showing "runningâ€Ļ" forever. + if m.composer != nil && m.composer.submitting { + m.composer.submitting = false + if msg.err != nil { + m.composer.problem = msg.err.Error() + m.appendLogError(msg.err.Error()) + return m, nil + } + m.closeComposer() + return m.applyResult(msg.result) + } + + if msg.err != nil { + m.toast = msg.err.Error() + m.appendLogError(msg.err.Error()) + return m, nil + } + m.prompt.Reset() + return m.applyResult(msg.result) +} + +// applyResult applies a Result. It is separate from applyCommand so the composer can reuse it. +func (m *Model) applyResult(result Result) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + for _, line := range result.Lines { + m.appendLog(line) + } + if len(result.Events) > 0 { + for _, event := range result.Events { + if event.Kind == plcsession.EventBrowse && len(event.Tags) > 0 { + m.catalogue = event.Tags + m.catalogueConnection = event.Connection + } + } + m.events = append(m.events, result.Events...) + m.trimEvents() + m.refreshMessages() + } + if result.Clear.Has(ClearMessages) { + m.catalogue, m.catalogueConnection = nil, "" + m.events = nil + m.refreshMessages() + m.detail.SetContent("") + } + if result.Clear.Has(ClearConsole) { + m.logLines = nil + m.logView.SetContent("") + } + if result.ConnectionsChanged || result.DriversChanged { + m.refreshSidebar() + } + if result.LogLevel != nil { + m.options.Config.LogLevel = result.LogLevel.String() + } + if result.Compose != nil { + m.openComposer(*result.Compose) + } + if result.Stream != nil { + m.streams = append(m.streams, result.Stream) + cmds = append(cmds, awaitStream(result.Stream)) + } + if result.Quit { + return m.shutdown() + } + return m, tea.Batch(cmds...) +} + +// awaitStream reads ONE event and re-arms itself. +// +// Reading in a loop from a goroutine would mean touching the model from outside Update. One +// value per command is how Bubble Tea consumes a channel. +func awaitStream(stream *Stream) tea.Cmd { + return func() tea.Msg { + event, open := <-stream.Events + return streamEventMsg{stream: stream, event: event, open: open} + } +} + +// applyStreamEvent folds a subscription event in and re-arms the reader. +func (m *Model) applyStreamEvent(msg streamEventMsg) (tea.Model, tea.Cmd) { + if !msg.open { + m.dropStream(msg.stream) + m.appendLog("subscription ended " + msg.stream.Connection + " " + msg.stream.Label) + return m, nil + } + m.events = append(m.events, msg.event) + m.trimEvents() + m.refreshMessages() + return m, awaitStream(msg.stream) +} + +// dropStream forgets a finished subscription. +func (m *Model) dropStream(stream *Stream) { + for i, candidate := range m.streams { + if candidate == stream { + m.streams = append(m.streams[:i], m.streams[i+1:]...) + return + } + } +} + +// trimEvents bounds the message list, as the old UI's MaxOutputLines did. +func (m *Model) trimEvents() { + limit := m.options.Config.MaxOutputLines + if limit > 0 && len(m.events) > limit { + m.events = m.events[len(m.events)-limit:] + } +} + +// appendLog adds a console line at info level. +func (m *Model) appendLog(line string) { + m.appendLogAt("info", line) +} + +// appendLogError adds a console line at error level, so a failure has shape in the pane rather +// than being one more grey line among many. +func (m *Model) appendLogError(line string) { + m.appendLogAt("error", line) +} + +// appendLogAt adds a console line at a level, bounded by the configured maximum. +func (m *Model) appendLogAt(level, line string) { + stamp := m.options.Now().Format("15:04:05") + styled := m.theme.Muted.Render(stamp) + " " + + m.theme.LogLevel(level).Render(logLevelTag(level)) + " " + line + m.logLines = append(m.logLines, styled) + if limit := m.options.Config.MaxConsoleLines; limit > 0 && len(m.logLines) > limit { + m.logLines = m.logLines[len(m.logLines)-limit:] + } + m.logView.SetContent(strings.Join(m.logLines, "\n")) + m.logView.GotoBottom() +} + +// logLevelTag is the three-letter level marker, which keeps the lines aligned. +func logLevelTag(level string) string { + switch level { + case "error": + return "ERR" + case "warn": + return "WRN" + default: + return "INF" + } +} + +// AppendLog exposes the console so a caller can write a startup banner before the program runs. +func (m *Model) AppendLog(line string) { m.appendLog(line) } + +// FocusName names the region holding the keyboard, for tests. +func (m *Model) FocusName() string { return m.focusName() } + +// ConfirmingQuit reports whether the quit question is up, for tests. +func (m *Model) ConfirmingQuit() bool { return m.confirmQuit } + +// LogLines exposes the console contents, for tests. +func (m *Model) LogLines() []string { return append([]string(nil), m.logLines...) } + +// Toast exposes the pending error line, for tests. +func (m *Model) Toast() string { return m.toast } + +// SetFilter sets the message filter. +func (m *Model) SetFilter(filter string) { + m.filter = filter + m.refreshMessages() +} + +// filteredEvents returns the events the table shows. +// +// bubbles/table has no filtering of its own -- verified: the package contains no reference to +// filtering at all -- so the filter is applied to the row slice here and the "n of m" counter +// is derived from the two lengths. +func (m *Model) filteredEvents() []plcsession.Event { + if m.filter == "" { + return m.events + } + needle := strings.ToLower(m.filter) + var out []plcsession.Event + for _, event := range m.events { + if strings.Contains(strings.ToLower(eventHaystack(event)), needle) { + out = append(out, event) + } + } + return out +} + +// eventHaystack is the text a filter matches against. +func eventHaystack(event plcsession.Event) string { + parts := []string{string(event.Kind), event.Connection, event.Summary} + for _, tag := range event.Tags { + parts = append(parts, tag.Address, tag.Name, tag.Value, tag.Code) + } + return strings.Join(parts, " ") +} + +// refreshMessages rebuilds the table rows from the events. +func (m *Model) refreshMessages() { + events := m.filteredEvents() + columns := m.messages.Columns() + // The row has to match the columns the table was given: a cell more or fewer than there are + // columns is silently dropped or leaves a hole. + withConnection, withNumber, withOperation := false, false, false + stampWidth := 9 + for _, column := range columns { + switch column.Title { + case "connection": + withConnection = true + case "#": + withNumber = true + case "op": + withOperation = true + case "time": + stampWidth = column.Width + } + } + layout := "15:04:05" + if stampWidth >= 13 { + layout = "15:04:05.000" + } + + rows := make([]table.Row, 0, len(events)) + for i, event := range events { + var row table.Row + if withNumber { + row = append(row, strconv.Itoa(i+1)) + } + row = append(row, event.Received.Format(layout)) + if withOperation { + row = append(row, string(event.Kind)) + } + if withConnection { + row = append(row, event.Connection) + } + // The outcome carries its own colour. bubbles/table styles every cell alike, so the + // meaning has to travel in the cell's own text; the table measures width with an + // ANSI-aware width function, so the escapes do not disturb the column arithmetic. + rows = append(rows, append(row, firstTagAddress(event), m.renderCode(event))) + } + m.messages.SetRows(rows) + if m.follow && len(rows) > 0 { + m.messages.GotoBottom() + } + m.syncDetail() +} + +// firstTagAddress is the tag column's content. +func firstTagAddress(event plcsession.Event) string { + switch len(event.Tags) { + case 0: + return "" + case 1: + return event.Tags[0].Address + default: + return fmt.Sprintf("%s +%d", event.Tags[0].Address, len(event.Tags)-1) + } +} + +// renderCode is eventCode with its meaning in colour: an OK is green and a failure red, so a +// bad row is findable in a full pane without reading it. +func (m *Model) renderCode(event plcsession.Event) string { + code := eventCode(event) + switch { + case code == "": + return "" + case code == plcsession.ResponseCodeOK: + return m.theme.Ok.Render(code) + default: + return m.theme.Err.Render(code) + } +} + +// eventCode summarises an event's outcome for the code column. +func eventCode(event plcsession.Event) string { + failed := 0 + for _, tag := range event.Tags { + if !tag.Succeeded() { + failed++ + } + } + switch { + case len(event.Tags) == 0: + return "" + case failed == 0: + return plcsession.ResponseCodeOK + default: + return fmt.Sprintf("%d bad", failed) + } +} + +// syncDetail rebuilds the detail pane for the selected message. +func (m *Model) syncDetail() { + events := m.filteredEvents() + cursor := m.messages.Cursor() + if cursor < 0 || cursor >= len(events) { + m.detail.SetContent("") + return + } + event := events[cursor] + if m.detailBytes { + m.detail.SetContent(renderEventFrames(m.theme, event, m.options.Frames)) + return + } + m.detail.SetContent(renderEventDetail(m.theme, event, m.detail.Width())) +} + +// SelectedEvent exposes the highlighted message, for tests and for the detail pane. +func (m *Model) SelectedEvent() (plcsession.Event, bool) { + events := m.filteredEvents() + cursor := m.messages.Cursor() + if cursor < 0 || cursor >= len(events) { + return plcsession.Event{}, false + } + return events[cursor], true +} + +// detailShowsCatalogue reports whether the detail pane is currently displaying the same tag +// list the sidebar keeps as its catalogue. +func (m *Model) detailShowsCatalogue() bool { + event, ok := m.SelectedEvent() + if !ok || event.Kind != plcsession.EventBrowse { + return false + } + return event.Connection == m.catalogueConnection && len(event.Tags) == len(m.catalogue) +} + +// FailedCount reports how many held messages carry a failed tag. +func (m *Model) FailedCount() int { + failed := 0 + for _, event := range m.events { + for _, tag := range event.Tags { + if !tag.Succeeded() { + failed++ + break + } + } + } + return failed +} + +// EventCount reports how many messages are held and how many the filter shows. +func (m *Model) EventCount() (shown, total int) { + return len(m.filteredEvents()), len(m.events) +} + +// refreshSidebar re-reads the session into the sidebar rows. +func (m *Model) refreshSidebar() { + glyphs := m.theme.Glyphs + rows := []sidebarRow{{heading: "CONNECTIONS"}} + connections := m.options.Session.Connections() + if len(connections) == 0 { + // An empty pane that names the command to run beats an empty pane. + rows = append(rows, sidebarRow{label: "none, try: connect"}) + } + for _, connection := range connections { + rows = append(rows, sidebarRow{ + label: connection.ID, + marker: glyphs.Yes, + connection: connection.ID, + }) + } + + rows = append(rows, sidebarRow{heading: "DRIVERS"}) + drivers := m.options.Session.Drivers() + if len(drivers) == 0 { + rows = append(rows, sidebarRow{label: "none, try: register"}) + } + for _, driver := range drivers { + marker := glyphs.No + if driver.SupportsDiscovery { + marker = glyphs.Yes + } + rows = append(rows, sidebarRow{ + label: driver.Code, + detail: driver.Name, + marker: marker, + driver: driver.Code, + }) + } + + m.sidebarRows = rows + m.clampSidebar() +} + +// clampSidebar keeps the cursor on a selectable row. +func (m *Model) clampSidebar() { + if len(m.sidebarRows) == 0 { + m.sidebarIndex = 0 + return + } + m.sidebarIndex = min(max(m.sidebarIndex, 0), len(m.sidebarRows)-1) + if !m.sidebarRows[m.sidebarIndex].selectable() { + m.moveSidebar(1) + } +} + +// moveSidebar walks to the next selectable row in a direction, stopping at the ends. +func (m *Model) moveSidebar(delta int) { + for i := m.sidebarIndex + delta; i >= 0 && i < len(m.sidebarRows); i += delta { + if m.sidebarRows[i].selectable() { + m.sidebarIndex = i + return + } + } +} + +// activateSidebarRow acts on the highlighted row. +// +// Both of these handlers were "// TODO: disconnect popup" in the previous UI: selecting a +// connection or a driver did nothing at all, which is the kind of dead affordance this port +// exists to remove. Selecting pre-fills the prompt rather than acting immediately, because a +// destructive action one keystroke away from a cursor move is too easy to trigger by accident. +func (m *Model) activateSidebarRow() (tea.Model, tea.Cmd) { + row, ok := m.SidebarSelection() + if !ok { + return m, nil + } + switch { + case row.connection != "": + m.prompt.SetValue("read " + row.connection + " ") + m.focusPrompt() + case row.driver != "": + m.prompt.SetValue("discover " + row.driver) + m.focusPrompt() + } + return m, nil +} + +// SidebarSelection exposes the highlighted row, for tests. +func (m *Model) SidebarSelection() (sidebarRow, bool) { + if m.sidebarIndex < 0 || m.sidebarIndex >= len(m.sidebarRows) { + return sidebarRow{}, false + } + return m.sidebarRows[m.sidebarIndex], true +} + +// PromptValue exposes the command line's contents, for tests. +func (m *Model) PromptValue() string { return m.prompt.Value() } + +// PromptCompletions exposes the completion candidates, for tests. +func (m *Model) PromptCompletions() []string { return m.prompt.Completions() } + +// PromptCompletionOpen reports whether the completion list is showing, for tests. +func (m *Model) PromptCompletionOpen() bool { return m.prompt.CompletionOpen() } + +// PromptBusy reports whether a command is in flight, for tests. +func (m *Model) PromptBusy() bool { return m.prompt.Busy() } + +// resize recomputes the layout and resizes every child. +func (m *Model) resize(size tui.Size) { + m.layout = tui.Compute(size) + m.ready = true + if m.layout.Mode == tui.ModeTooSmall { + return + } + + // The prompt and the help footer span the full width less one cell of margin either side. + // One cell narrower than the screen, not two: renderPrompt draws a single gutter cell to + // the prompt's left, and together they have to cover the row exactly, or the command bar's + // band stops one cell short of the right edge. + m.prompt.SetWidth(max(size.Width-1, 1)) + m.help.SetWidth(max(size.Width-2, 1)) + + mainWidth := m.layout.MainWidth + if m.layout.Mode == tui.ModeCompact { + mainWidth = size.Width + } + + // Every pane in this layout is a full-height column: the sidebar, the messages and the + // detail sit side by side, so each viewport gets the whole body less its own two border + // rows. This used to halve the height for the messages and give the remainder to the + // detail, which is the geometry of a layout that stacked the two -- and that layout no + // longer exists. The cost was that both panes drew a full-height box and then filled the + // lower half of it with blank rows, and content past the halfway mark was simply clipped: + // a browse of eight tags lost the action hint drawn beneath them. + paneHeight := max(m.layout.BodyHeight-2, 1) + + m.messages.SetWidth(max(mainWidth-2, 1)) + m.messages.SetHeight(paneHeight) + m.messages.SetColumns(messageColumns(max(mainWidth-2, 12), m.showConnectionColumn())) + + m.detail.SetWidth(max(detailWidth(m.layout)-2, 1)) + m.detail.SetHeight(paneHeight) + + m.logView.SetWidth(max(size.Width-2, 1)) + m.logView.SetHeight(max(m.layout.LogHeight-2, 1)) + + m.refreshMessages() +} + +// detailWidth is the width the detail pane gets in the current layout. +func detailWidth(layout tui.Layout) int { + if layout.ShowDetail && layout.DetailWidth > 0 { + return layout.DetailWidth + } + return layout.MainWidth +} + +// Quitting reports whether the model has asked to exit, for tests. +func (m *Model) Quitting() bool { return m.quit } diff --git a/plc4go/tools/plc4xbrowser/browser/model_test.go b/plc4go/tools/plc4xbrowser/browser/model_test.go new file mode 100644 index 000000000..905ceaed4 --- /dev/null +++ b/plc4go/tools/plc4xbrowser/browser/model_test.go @@ -0,0 +1,807 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package browser + +import ( + "strconv" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/plcsession" + "github.com/apache/plc4x-extras/plc4go/tools/internal/tui" +) + +// The model is driven directly with typed messages: no terminal, no goroutines, no sleeps. +// That is the point of the port. The implementation this replaces could not be tested at all, +// because its state lived in global tview widgets mutated from goroutines. + +// testTheme is colourless and ASCII, so rendered output is stable and measurable. +func testTheme() tui.Theme { + return tui.NewTheme(tui.Options{Dark: true, NoColor: true, ASCII: true}) +} + +// newTestModel builds a model over a connected demo session. +func newTestModel(t *testing.T) *Model { + t.Helper() + return newTestModelWithTheme(t, testTheme()) +} + +// newTestModelWithTheme is newTestModel with the theme chosen by the caller, so that a test can +// exercise the coloured themes rather than only the plain one the assertions read. +func newTestModelWithTheme(t *testing.T, theme tui.Theme) *Model { + t.Helper() + + session := plcsession.NewDemo(plcsession.DemoOptions{ + SubscriptionInterval: time.Millisecond, + Now: steppingClock(), + }) + t.Cleanup(func() { _ = session.Close() }) + _, err := session.RegisterDriver(plcsession.DemoProtocol) + require.NoError(t, err) + _, err = session.Connect(t.Context(), plcsession.DemoDeviceOne) + require.NoError(t, err) + + config := NewConfig() + model := NewModel(Options{ + Session: session, + Config: &config, + Demo: true, + Version: "1.0.0-TEST", + Theme: &theme, + Now: steppingClock(), + }) + return model +} + +// sized runs Init and drives a window-size message through the model, which is the sequence +// the runtime performs before any user input. Skipping Init leaves the prompt unfocused, and +// an unfocused text input silently discards every keystroke. +func sized(t *testing.T, model *Model, width, height int) *Model { + t.Helper() + model.Init() + next, _ := model.Update(tea.WindowSizeMsg{Width: width, Height: height}) + updated, ok := next.(*Model) + require.True(t, ok) + return updated +} + +// press sends a key and returns the command it produced. +func press(t *testing.T, model *Model, key tea.KeyPressMsg) tea.Cmd { + t.Helper() + _, cmd := model.Update(key) + return cmd +} + +// runCmd executes a command and feeds its message back, which is what the runtime does. +func runCmd(t *testing.T, model *Model, cmd tea.Cmd) { + t.Helper() + require.NotNil(t, cmd, "expected the model to return a command") + msg := cmd() + require.NotNil(t, msg, "expected the command to produce a message") + model.Update(msg) +} + +// renderLines renders the screen and returns its lines. +func renderLines(t *testing.T, model *Model) []string { + t.Helper() + view := model.View() + assert.True(t, view.AltScreen, "the browser is a full-screen application") + return strings.Split(view.Content, "\n") +} + +// --- geometry --- + +// TestEveryRenderedLineIsExactlyTheTerminalWidth is the property every multi-pane layout rests +// on. One line a cell too wide tears every row beneath it. +func TestEveryRenderedLineIsExactlyTheTerminalWidth(t *testing.T) { + for _, size := range []tui.Size{ + {Width: 120, Height: 40}, + {Width: 100, Height: 30}, + {Width: 100, Height: 24}, + {Width: 90, Height: 24}, + {Width: 80, Height: 24}, + {Width: 70, Height: 20}, + {Width: 60, Height: 12}, + } { + t.Run(size.String(), func(t *testing.T) { + model := sized(t, newTestModel(t), size.Width, size.Height) + lines := renderLines(t, model) + + assert.Len(t, lines, size.Height, "the screen must be exactly the terminal height") + for i, line := range lines { + assert.Equal(t, size.Width, lipgloss.Width(line), + "line %d is %d cells, want %d: %q", i, lipgloss.Width(line), size.Width, line) + } + }) + } +} + +// TestThePromptIsAlwaysVisible is the regression test for the defect that motivated the whole +// port: below 100 columns the previous UI added its command area with a zero row and column +// span, which the grid discarded, so the input was never drawn and the user typed blind. +func TestThePromptIsAlwaysVisible(t *testing.T) { + for _, size := range []tui.Size{ + {Width: 200, Height: 50}, + {Width: 100, Height: 24}, + {Width: 80, Height: 24}, + {Width: 70, Height: 16}, + {Width: 60, Height: 12}, + } { + t.Run(size.String(), func(t *testing.T) { + model := sized(t, newTestModel(t), size.Width, size.Height) + joined := strings.Join(renderLines(t, model), "\n") + assert.Contains(t, joined, "$", "the command prompt must be on screen at %s", size) + }) + } +} + +// TestTheHelpFooterAlwaysAdvertisesQuit guards against bubbles/help ellipsizing the tail away +// at narrow widths, which would hide the one binding a stuck user needs. +func TestTheHelpFooterAlwaysAdvertisesQuit(t *testing.T) { + for _, width := range []int{120, 100, 80, 70, 60} { + t.Run(tui.Size{Width: width, Height: 24}.String(), func(t *testing.T) { + model := sized(t, newTestModel(t), width, 24) + joined := strings.Join(renderLines(t, model), "\n") + assert.Contains(t, joined, "quit", "the footer must still advertise quit at %d columns", width) + }) + } +} + +// TestTheStatusBarDegradesRatherThanOverflowing pins the fix for a header that is about 91 +// cells as designed and therefore does not fit the 80-column target. +func TestTheStatusBarDegradesRatherThanOverflowing(t *testing.T) { + for _, width := range []int{120, 100, 80, 70, 60} { + t.Run(tui.Size{Width: width, Height: 24}.String(), func(t *testing.T) { + model := sized(t, newTestModel(t), width, 24) + status := renderLines(t, model)[0] + assert.Equal(t, width, lipgloss.Width(status)) + assert.Contains(t, status, "PLC4X Browser", "the tool's name is the one part that never drops") + }) + } +} + +func TestATerminalBelowTheMinimumSaysSoRatherThanDrawingSomethingBroken(t *testing.T) { + model := sized(t, newTestModel(t), 40, 8) + joined := strings.Join(renderLines(t, model), "\n") + assert.Contains(t, joined, tui.TooSmallMessage()) +} + +// TestTheDemoBadgeIsShown matters because a user must never mistake simulated values for +// readings from real hardware. +func TestTheDemoBadgeIsShown(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + assert.Contains(t, renderLines(t, model)[0], "DEMO") +} + +func TestWithoutDemoThereIsNoBadge(t *testing.T) { + session := plcsession.NewDemo(plcsession.DemoOptions{}) + t.Cleanup(func() { _ = session.Close() }) + config := NewConfig() + theme := testTheme() + model := NewModel(Options{Session: session, Config: &config, Theme: &theme, Now: steppingClock()}) + model = sized(t, model, 120, 30) + assert.NotContains(t, renderLines(t, model)[0], "DEMO") +} + +// --- focus --- + +// TestFocusStartsOnThePrompt: these are REPLs, so the command line has the keyboard by default. +func TestFocusStartsOnThePrompt(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + _, promptFocused := model.Focused() + assert.True(t, promptFocused) +} + +// TestTabWalksThePaneRingAndReturnsToThePrompt guards against a focus ring that can strand the +// user away from the command line. +func TestTabWalksThePaneRingAndReturnsToThePrompt(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + + // Shift+tab from the prompt enters the ring at the far end and walks back to the prompt. + press(t, model, tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift}) + _, promptFocused := model.Focused() + require.False(t, promptFocused, "shift+tab should leave the prompt") + + // Walking forward past the last pane must land back on the prompt rather than wrapping + // endlessly through the panes. + for range int(paneCount) + 1 { + press(t, model, tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift}) + } + _, promptFocused = model.Focused() + assert.True(t, promptFocused, "the ring must return to the prompt") +} + +// TestEscapeReturnsFocusToThePrompt covers the gap the design review found: no binding brought +// the keyboard back from a pane. +func TestEscapeReturnsFocusToThePrompt(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + press(t, model, tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift}) + require.False(t, mustPromptFocused(model)) + + press(t, model, tea.KeyPressMsg{Code: tea.KeyEscape}) + assert.True(t, mustPromptFocused(model), "escape must bring the keyboard back to the prompt") +} + +// mustPromptFocused reports whether the prompt holds the keyboard. +// quit takes the tool through the quit question, which every key-driven exit now goes through. +func quit(t *testing.T, model *Model) { + t.Helper() + press(t, model, tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + require.True(t, model.ConfirmingQuit(), "ctrl+c should ask before ending the session") + press(t, model, tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) +} + +func mustPromptFocused(model *Model) bool { + _, promptFocused := model.Focused() + return promptFocused +} + +// TestTypingACommandNameDoesNotTriggerPaneActions is the scoping rule in practice: the letters +// of "quit" include q, which is a pane binding. +func TestTypingACommandNameDoesNotTriggerPaneActions(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + for _, r := range "quit" { + press(t, model, tea.KeyPressMsg{Code: r, Text: string(r)}) + } + assert.False(t, model.Quitting(), "typing at the prompt must not fire pane bindings") + assert.Equal(t, "quit", model.PromptValue()) +} + +// --- commands --- + +func TestSubmittingACommandRunsItAndRecordsIt(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + + _, cmd := model.Update(tui.PromptSubmitMsg{Line: "browse-direct " + plcsession.DemoDeviceOne}) + runCmd(t, model, cmd) + + shown, total := model.EventCount() + assert.Positive(t, total, "browsing the demo device should produce messages") + assert.Equal(t, shown, total) + assert.Contains(t, strings.Join(model.LogLines(), "\n"), "browse-direct") +} + +func TestAFailedCommandRaisesAToastThatSurvivesUntilDismissed(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + + _, cmd := model.Update(tui.PromptSubmitMsg{Line: "read-direct demo://nowhere temp/1"}) + runCmd(t, model, cmd) + + require.NotEmpty(t, model.Toast(), "a failed command must leave a visible error") + assert.Contains(t, model.Toast(), "not connected") + + // It must still be on screen: the old UI let errors scroll away in a ten-row console. + assert.Contains(t, strings.Join(renderLines(t, model), "\n"), "not connected") + + press(t, model, tea.KeyPressMsg{Code: tea.KeyEscape}) + assert.Empty(t, model.Toast(), "escape dismisses the toast") +} + +func TestReadDirectShowsTheValueInTheDetailPane(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + + _, cmd := model.Update(tui.PromptSubmitMsg{Line: "read-direct " + plcsession.DemoDeviceOne + " temp/1"}) + runCmd(t, model, cmd) + + event, ok := model.SelectedEvent() + require.True(t, ok, "the new message should be selected") + require.Len(t, event.Tags, 1) + assert.Equal(t, "temp/1", event.Tags[0].Address) + + joined := strings.Join(renderLines(t, model), "\n") + assert.Contains(t, joined, "temp/1", "the tag should appear on screen") +} + +func TestQuitCommandShutsDown(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + _, cmd := model.Update(tui.PromptSubmitMsg{Line: "quit"}) + runCmd(t, model, cmd) + assert.True(t, model.Quitting()) +} + +func TestClearEmptiesTheMessageTable(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + _, cmd := model.Update(tui.PromptSubmitMsg{Line: "browse-direct " + plcsession.DemoDeviceOne}) + runCmd(t, model, cmd) + _, total := model.EventCount() + require.Positive(t, total) + + _, cmd = model.Update(tui.PromptSubmitMsg{Line: "clear messages"}) + runCmd(t, model, cmd) + _, total = model.EventCount() + assert.Zero(t, total) +} + +func TestTheMessageFilterNarrowsTheTableWithoutLosingTheEvents(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + // A mix of tags, so a filter has something to exclude. A browse is a single message now, + // so it cannot supply the variety on its own. + for _, address := range []string{"temp/1", "press/1", "temp/2", "flow/1"} { + _, cmd := model.Update(tui.PromptSubmitMsg{Line: "read-direct " + plcsession.DemoDeviceOne + " " + address}) + runCmd(t, model, cmd) + } + _, total := model.EventCount() + require.Greater(t, total, 1) + + model.SetFilter("temp") + shown, stillTotal := model.EventCount() + assert.Equal(t, total, stillTotal, "filtering must not discard events") + assert.Less(t, shown, total, "the filter must actually narrow the table") + assert.Positive(t, shown) + + model.SetFilter("") + shown, _ = model.EventCount() + assert.Equal(t, total, shown) +} + +// --- subscriptions --- + +// TestASubscriptionStreamsIntoTheMessageTable exercises the re-arming command that drains a +// channel without any goroutine touching model state. +func TestASubscriptionStreamsIntoTheMessageTable(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + + _, cmd := model.Update(tui.PromptSubmitMsg{Line: "subscribe-direct " + plcsession.DemoDeviceOne + " temp/1"}) + require.NotNil(t, cmd) + msg := cmd() + done, ok := msg.(commandDoneMsg) + require.True(t, ok) + require.NoError(t, done.err) + require.NotNil(t, done.result.Stream) + + _, streamCmd := model.Update(done) + require.NotNil(t, streamCmd, "the model must arm a reader for the stream") + + // One turn of the loop: the reader yields an event, which the model folds in and re-arms. + before, _ := model.EventCount() + eventMsg := streamCmd() + _, again := model.Update(eventMsg) + after, _ := model.EventCount() + + assert.Greater(t, after, before, "a subscription event must reach the table") + assert.NotNil(t, again, "the reader must re-arm itself") + + // Shutting down must cancel the subscription rather than leak it. Quitting is confirmed, + // so it takes the question and then the answer. + quit(t, model) + assert.True(t, model.Quitting()) +} + +// --- composer --- + +func TestTheComposerOpensForEachOfTheFourCommands(t *testing.T) { + for _, name := range []string{"read", "write", "browse", "subscribe"} { + t.Run(name, func(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + _, cmd := model.Update(tui.PromptSubmitMsg{Line: name + " " + plcsession.DemoDeviceOne}) + runCmd(t, model, cmd) + assert.True(t, model.ComposerOpen(), "%s must open the request composer", name) + + joined := strings.Join(renderLines(t, model), "\n") + assert.Contains(t, strings.ToLower(joined), "request", "the form should be on screen") + }) + } +} + +func TestTheComposerValidatesBeforeSubmitting(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + _, cmd := model.Update(tui.PromptSubmitMsg{Line: "read " + plcsession.DemoDeviceOne}) + runCmd(t, model, cmd) + require.True(t, model.ComposerOpen()) + + // The pre-filled row has no address yet, so submitting must complain rather than send. + press(t, model, tea.KeyPressMsg{Code: tea.KeyEnter}) + assert.Contains(t, model.ComposerProblem(), "address") + assert.True(t, model.ComposerOpen(), "an invalid form stays open") +} + +func TestTheComposerRunsAValidRequest(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + _, cmd := model.Update(tui.PromptSubmitMsg{Line: "read " + plcsession.DemoDeviceOne + " temp/1"}) + runCmd(t, model, cmd) + require.True(t, model.ComposerOpen()) + + _, submitCmd := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + require.NotNil(t, submitCmd) + model.Update(submitCmd()) + + shown, _ := model.EventCount() + assert.Positive(t, shown, "the composed read must produce a message") +} + +func TestEscapeClosesTheComposerAndReturnsToThePrompt(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + _, cmd := model.Update(tui.PromptSubmitMsg{Line: "read " + plcsession.DemoDeviceOne}) + runCmd(t, model, cmd) + require.True(t, model.ComposerOpen()) + + press(t, model, tea.KeyPressMsg{Code: tea.KeyEscape}) + assert.False(t, model.ComposerOpen()) + assert.True(t, mustPromptFocused(model)) +} + +func TestTheComposerCanGrowATagRow(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + _, cmd := model.Update(tui.PromptSubmitMsg{Line: "read " + plcsession.DemoDeviceOne + " temp/1"}) + runCmd(t, model, cmd) + require.True(t, model.ComposerOpen()) + + before := len(model.composer.rows) + press(t, model, tea.KeyPressMsg{Code: 'n', Mod: tea.ModCtrl}) + assert.Equal(t, before+1, len(model.composer.rows), "ctrl+n adds a tag row") +} + +// TestTheComposerOverlaysWithoutBreakingTheGeometry: the modal is composited over the panes, +// and compositing must not change the screen's shape. +func TestTheComposerOverlaysWithoutBreakingTheGeometry(t *testing.T) { + model := sized(t, newTestModel(t), 100, 30) + _, cmd := model.Update(tui.PromptSubmitMsg{Line: "read " + plcsession.DemoDeviceOne}) + runCmd(t, model, cmd) + require.True(t, model.ComposerOpen()) + + lines := renderLines(t, model) + assert.Len(t, lines, 30) + for i, line := range lines { + assert.Equal(t, 100, lipgloss.Width(line), "line %d: %q", i, line) + } +} + +// --- sidebar --- + +// TestSelectingASidebarRowDoesSomething is the regression test for the dead affordances: both +// handlers in the previous UI contained only a TODO comment. +func TestSelectingASidebarRowDoesSomething(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + + // Enter the pane ring and land on the sidebar. + press(t, model, tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift}) + for { + focus, promptFocused := model.Focused() + if !promptFocused && focus == paneSidebar { + break + } + press(t, model, tea.KeyPressMsg{Code: tea.KeyTab, Mod: tea.ModShift}) + } + + row, ok := model.SidebarSelection() + require.True(t, ok) + require.True(t, row.selectable()) + + press(t, model, tea.KeyPressMsg{Code: tea.KeyEnter}) + assert.NotEmpty(t, model.PromptValue(), "selecting a row must do something, not nothing") +} + +func TestTheSidebarCursorSkipsSectionHeadings(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + for range 20 { + row, ok := model.SidebarSelection() + require.True(t, ok) + assert.True(t, row.selectable(), "the cursor must never rest on a heading") + model.moveSidebar(1) + } +} + +func TestTheSidebarShowsAnEmptyStateNamingTheNextCommand(t *testing.T) { + session := plcsession.NewDemo(plcsession.DemoOptions{}) + t.Cleanup(func() { _ = session.Close() }) + config := NewConfig() + theme := testTheme() + model := NewModel(Options{Session: session, Config: &config, Theme: &theme, Now: steppingClock()}) + model = sized(t, model, 120, 30) + + joined := strings.Join(renderLines(t, model), "\n") + assert.Contains(t, joined, "connect", "an empty pane should name the command that fills it") +} + +// --- background colour --- + +// TestTheThemeFollowsTheTerminalBackground covers the only reliable way to stay legible. +func TestTheThemeFollowsTheTerminalBackground(t *testing.T) { + session := plcsession.NewDemo(plcsession.DemoOptions{}) + t.Cleanup(func() { _ = session.Close() }) + config := NewConfig() + // No Theme override, so the model derives one from the reported background. + model := NewModel(Options{Session: session, Config: &config, Now: steppingClock()}) + model = sized(t, model, 120, 30) + + dark := model.theme.IsDark() + model.Update(tea.BackgroundColorMsg{Color: lipgloss.Color("#ffffff")}) + assert.NotEqual(t, dark, model.theme.IsDark(), "a light background must switch the palette") +} + +// TestTheComposerClosesWhenItsRequestAnswers is the regression test for a form that stayed on +// screen showing "runningâ€Ļ" forever: the message carrying the answer was applied to the model +// but nothing ever cleared the submitting flag or dismissed the form. +func TestTheComposerClosesWhenItsRequestAnswers(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + + _, cmd := model.Update(tui.PromptSubmitMsg{Line: "read " + plcsession.DemoDeviceOne + " temp/1"}) + runCmd(t, model, cmd) + require.True(t, model.ComposerOpen()) + + _, submit := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + require.NotNil(t, submit) + model.Update(submit()) + + assert.False(t, model.ComposerOpen(), "a completed request must dismiss its form") + assert.True(t, mustPromptFocused(model), "and hand the keyboard back to the prompt") + + joined := strings.Join(renderLines(t, model), "\n") + assert.NotContains(t, joined, "running", "the form must not be left showing a running request") + + shown, _ := model.EventCount() + assert.Positive(t, shown, "the result must still reach the message table") +} + +// TestAFailedComposerRequestKeepsTheFormOpenWithTheReason: the point of a form is not having to +// retype it, so a failure must not throw the request away. +func TestAFailedComposerRequestKeepsTheFormOpenWithTheReason(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + + // label/1 exists but cannot be subscribed to, so the request fails at the session. + _, cmd := model.Update(tui.PromptSubmitMsg{Line: "subscribe " + plcsession.DemoDeviceOne + " label/1"}) + runCmd(t, model, cmd) + require.True(t, model.ComposerOpen()) + + _, submit := model.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + require.NotNil(t, submit) + model.Update(submit()) + + require.True(t, model.ComposerOpen(), "a failed request must leave the form open to be corrected") + assert.NotEmpty(t, model.ComposerProblem(), "and it must say why") + assert.NotContains(t, strings.Join(renderLines(t, model), "\n"), "running", + "the form must not still claim to be running") +} + +// TestPaneHotkeysJumpDirectly covers the navigation that was missing: the keymap declared +// alt+1..alt+4 but the model never acted on them, so the only way between four panes was +// cycling with tab. +func TestPaneHotkeysJumpDirectly(t *testing.T) { + for digit, want := range map[rune]pane{ + '1': paneSidebar, + '2': paneMessages, + '3': paneDetail, + '4': paneLog, + } { + t.Run(string(digit), func(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + // alt+N works from the prompt; the bare digit would be text there, by design. + // Text must be left empty on a modified key: with Text set, String() returns the + // text and the modifier is lost, which is also what a terminal does -- alt+1 sends + // an escape sequence, not the character. + press(t, model, tea.KeyPressMsg{Code: digit, Mod: tea.ModAlt}) + + focus, promptFocused := model.Focused() + assert.False(t, promptFocused, "a pane hotkey must move the keyboard out of the prompt") + assert.Equal(t, want, focus) + }) + } +} + +// TestABareDigitJumpsFromAnEmptyPromptAndIsTextOtherwise pins the compromise that makes pane +// hotkeys actually reachable. alt+digit is the nominal binding, but GNOME Terminal, Konsole and +// Windows Terminal all bind alt+digit to switching terminal tabs and never deliver it, so a +// bare digit has to work too -- and it can, safely, wherever it cannot be text. +func TestABareDigitJumpsFromAnEmptyPromptAndIsTextOtherwise(t *testing.T) { + t.Run("empty prompt jumps", func(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + require.Empty(t, model.PromptValue()) + + press(t, model, tea.KeyPressMsg{Code: '2', Text: "2"}) + focus, promptFocused := model.Focused() + assert.False(t, promptFocused, "a digit typed at an empty prompt is a pane hotkey") + assert.Equal(t, paneMessages, focus) + assert.Empty(t, model.PromptValue(), "and it must not leave the digit behind as text") + }) + + t.Run("mid-command it is text", func(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + for _, r := range "read-direct demo://plant-" { + press(t, model, tea.KeyPressMsg{Code: r, Text: string(r)}) + } + press(t, model, tea.KeyPressMsg{Code: '2', Text: "2"}) + + assert.True(t, mustPromptFocused(model), "a digit inside a command is text, not a hotkey") + assert.Equal(t, "read-direct demo://plant-2", model.PromptValue()) + }) +} + +// TestCtrlDEndsTheSessionOnAnEmptyLine is the shell rule, and the reason ctrl+d could not +// simply be added to the quit binding: bubbles/textinput owns it for delete-forward. +func TestCtrlDEndsTheSessionOnAnEmptyLine(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + require.Empty(t, model.PromptValue()) + + press(t, model, tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl}) + require.True(t, model.ConfirmingQuit(), "ctrl+d on an empty line offers to end the session") + assert.False(t, model.Quitting(), "but not before it is answered") + + press(t, model, tea.KeyPressMsg{Code: 'y', Text: "y"}) + assert.True(t, model.Quitting(), "ctrl+d on an empty line ends the session, as in any shell") +} + +func TestCtrlDWithTextTypedDoesNotQuit(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + for _, r := range "help" { + press(t, model, tea.KeyPressMsg{Code: r, Text: string(r)}) + } + require.NotEmpty(t, model.PromptValue()) + + press(t, model, tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl}) + assert.False(t, model.Quitting(), + "with a line in progress ctrl+d belongs to the text input, not to quitting") +} + +func TestCtrlDQuitsFromAPaneRegardlessOfThePrompt(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + for _, r := range "help" { + press(t, model, tea.KeyPressMsg{Code: r, Text: string(r)}) + } + // Move into a pane: there is nothing to delete there, so ctrl+d is unambiguous. A pane + // hotkey rather than shift+tab, because with a line typed the completion list is open and + // shift+tab belongs to it. + press(t, model, tea.KeyPressMsg{Code: '2', Mod: tea.ModAlt}) + require.False(t, mustPromptFocused(model)) + + press(t, model, tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl}) + require.True(t, model.ConfirmingQuit()) + press(t, model, tea.KeyPressMsg{Code: 'y', Text: "y"}) + assert.True(t, model.Quitting()) +} + +// TestTheByteViewSaysWhyDemoModeHasNoFrames is the honest half of the wire-byte view: a +// simulated device puts nothing on a wire, and inventing bytes for it would be worse than +// saying there are none. +func TestTheByteViewSaysWhyDemoModeHasNoFrames(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + _, cmd := model.Update(tui.PromptSubmitMsg{Line: "read-direct " + plcsession.DemoDeviceOne + " temp/1"}) + runCmd(t, model, cmd) + require.Positive(t, func() int { shown, _ := model.EventCount(); return shown }()) + + rendered := renderEventFrames(model.theme, mustSelectedEvent(t, model), nil) + assert.Contains(t, rendered, "no wire capture") + assert.Contains(t, rendered, "demo mode", "it must say why, not just that there is nothing") +} + +// TestTheByteViewShowsTheFramesOfARequest covers the association: the frames that crossed the +// transport while the request was in flight. +func TestTheByteViewShowsTheFramesOfARequest(t *testing.T) { + base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + log := plcsession.NewFrameLog(0) + log.Add(plcsession.FrameOutbound, base.Add(-time.Second), []byte("earlier")) + log.Add(plcsession.FrameOutbound, base.Add(time.Millisecond), []byte("~~~\r")) + log.Add(plcsession.FrameInbound, base.Add(2*time.Millisecond), []byte("322100AD\r\n")) + log.Add(plcsession.FrameInbound, base.Add(time.Minute), []byte("later")) + + event := plcsession.Event{ + Kind: plcsession.EventRead, + Connection: "c-bus://10.0.0.5", + Started: base, + Received: base.Add(5 * time.Millisecond), + } + + theme := testTheme() + rendered := renderEventFrames(theme, event, log) + + assert.Contains(t, rendered, "sent", "an outbound frame must be labelled") + assert.Contains(t, rendered, "received") + // The hex of "~~~\r" is 7e 7e 7e 0d. + assert.Contains(t, rendered, "7e 7e 7e 0d", "the request's own bytes must be shown") + assert.NotContains(t, rendered, "earlier", "a frame before the request must not be attributed to it") + assert.NotContains(t, rendered, "later", "nor one after it") + assert.Contains(t, rendered, "not one protocol message", + "the pane must say what a frame is, since a transport has no message framing") +} + +func TestTheByteViewSaysWhenNothingCrossedDuringTheRequest(t *testing.T) { + base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + log := plcsession.NewFrameLog(0) + log.Add(plcsession.FrameInbound, base.Add(time.Hour), []byte("unrelated")) + + event := plcsession.Event{Started: base, Received: base.Add(time.Millisecond)} + rendered := renderEventFrames(testTheme(), event, log) + assert.Contains(t, rendered, "no frames captured") + assert.NotContains(t, rendered, "unrelated") +} + +// TestTheDetailPaneTogglesToBytes covers the key that switches the view. +func TestTheDetailPaneTogglesToBytes(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + _, cmd := model.Update(tui.PromptSubmitMsg{Line: "read-direct " + plcsession.DemoDeviceOne + " temp/1"}) + runCmd(t, model, cmd) + + // Focus the detail pane directly, then toggle. + press(t, model, tea.KeyPressMsg{Code: '3', Mod: tea.ModAlt}) + focus, _ := model.Focused() + require.Equal(t, paneDetail, focus) + + require.False(t, model.detailBytes) + press(t, model, tea.KeyPressMsg{Code: 'b', Text: "b"}) + assert.True(t, model.detailBytes, "b must switch the detail pane to the wire bytes") + + press(t, model, tea.KeyPressMsg{Code: 'b', Text: "b"}) + assert.False(t, model.detailBytes, "and back again") +} + +// mustSelectedEvent returns the highlighted message or fails. +func mustSelectedEvent(t *testing.T, model *Model) plcsession.Event { + t.Helper() + event, ok := model.SelectedEvent() + require.True(t, ok, "a message should be selected") + return event +} + +// TestAHexDumpLinesUpItsColumns keeps a short final row from shifting the text column. +func TestAHexDumpLinesUpItsColumns(t *testing.T) { + theme := testTheme() + lines := hexDump(theme, []byte("hello world!!")) + require.Len(t, lines, 2, "thirteen bytes at eight per row is two rows") + + // The hex column is padded so the text column starts at the same offset on every row. The + // text itself is naturally shorter on a short final row, so the line widths differ; what + // has to line up is where the text begins. + assert.Equal(t, strings.Index(lines[0], "|"), strings.Index(lines[1], "|"), + "a short final row must pad its hex column so the text column lines up:\n%s\n%s", lines[0], lines[1]) + assert.Contains(t, lines[0], "|hello wo|") + assert.Contains(t, lines[1], "|rld!!|") +} + +func TestAHexDumpMarksUnprintableBytes(t *testing.T) { + lines := hexDump(testTheme(), []byte{0x00, 0x1f, 0x41, 0x7f}) + require.Len(t, lines, 1) + assert.Contains(t, lines[0], "00 1f 41 7f") + assert.Contains(t, lines[0], "|..A.|", "only printable ASCII belongs in the text column") +} + +// TestPaneTitlesShowTheirJumpKey is the discoverability half of the hotkey fix: the numbers +// were unreachable partly because nothing on screen said which number belonged to which pane. +func TestPaneTitlesShowTheirJumpKey(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + joined := strings.Join(renderLines(t, model), "\n") + + for i, name := range []string{"Session", "Messages", "Detail", "Log"} { + label := "[" + strconv.Itoa(i+1) + "] " + name + assert.Contains(t, joined, label, "the pane title must advertise its jump key") + } +} + +// TestEveryPaneNumberActuallyJumpsToTheNamedPane keeps the drawn label and the binding in +// step: a title saying [3] that jumps somewhere else would be worse than no label. +func TestEveryPaneNumberActuallyJumpsToTheNamedPane(t *testing.T) { + for i, want := range []pane{paneSidebar, paneMessages, paneDetail, paneLog} { + digit := rune('1' + i) + t.Run(string(digit), func(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + press(t, model, tea.KeyPressMsg{Code: digit, Text: string(digit)}) + + focus, promptFocused := model.Focused() + require.False(t, promptFocused) + assert.Equal(t, want, focus) + assert.Contains(t, paneTitle(focus), "["+strconv.Itoa(i+1)+"]", + "the pane reached must be the one the label names") + }) + } +} diff --git a/plc4go/tools/plc4xbrowser/browser/mouse.go b/plc4go/tools/plc4xbrowser/browser/mouse.go new file mode 100644 index 000000000..1cf384f9a --- /dev/null +++ b/plc4go/tools/plc4xbrowser/browser/mouse.go @@ -0,0 +1,304 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package browser + +import ( + "strconv" + "strings" + + tea "charm.land/bubbletea/v2" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/tui" +) + +// Mouse support. +// +// The tview interface this replaces enabled the mouse, so clicking a pane and scrolling with +// the wheel both worked. Dropping that would have been a silent capability regression, which +// is the one thing the port was not allowed to do. +// +// Bubble Tea gives coordinates rather than widgets, so the model has to know where it drew +// things. paneAt is the inverse of the layout: given a cell, which pane owns it. + +// paneRegion is where one pane was drawn, in screen cells. The bounds are inclusive of the +// border, because clicking a pane's border is still clicking that pane. +type paneRegion struct { + pane pane + x0, y0 int + x1, y1 int +} + +// contains reports whether a cell falls inside the region. +func (r paneRegion) contains(x, y int) bool { + return x >= r.x0 && x <= r.x1 && y >= r.y0 && y <= r.y1 +} + +// paneRegions returns where each pane sits in the current layout. +// +// It mirrors render's assembly order: the status line, then the pane row, then the log drawer. +// Compact mode draws only the message pane, so only that one is clickable. +func (m *Model) paneRegions() []paneRegion { + if m.layout.Mode == tui.ModeTooSmall { + return nil + } + + const statusRows = 1 + bodyTop := statusRows + bodyBottom := bodyTop + m.layout.BodyHeight - 1 + + if m.layout.Mode == tui.ModeCompact { + regions := []paneRegion{ + {pane: paneMessages, x0: 0, y0: bodyTop, x1: m.layout.Size.Width - 1, y1: bodyBottom}, + } + if m.layout.ShowLog { + regions = append(regions, paneRegion{ + pane: paneLog, x0: 0, y0: bodyBottom + 1, + x1: m.layout.Size.Width - 1, y1: bodyBottom + m.layout.LogHeight, + }) + } + return regions + } + + sidebarEnd := m.layout.SidebarWidth - 1 + mainEnd := sidebarEnd + m.layout.MainWidth + regions := []paneRegion{ + {pane: paneSidebar, x0: 0, y0: bodyTop, x1: sidebarEnd, y1: bodyBottom}, + {pane: paneMessages, x0: sidebarEnd + 1, y0: bodyTop, x1: mainEnd, y1: bodyBottom}, + } + if m.layout.ShowDetail { + regions = append(regions, paneRegion{ + pane: paneDetail, x0: mainEnd + 1, y0: bodyTop, + x1: mainEnd + m.layout.DetailWidth, y1: bodyBottom, + }) + } + if m.layout.ShowLog { + regions = append(regions, paneRegion{ + pane: paneLog, x0: 0, y0: bodyBottom + 1, + x1: m.layout.Size.Width - 1, y1: bodyBottom + m.layout.LogHeight, + }) + } + return regions +} + +// paneAt reports which pane owns a cell. +func (m *Model) paneAt(x, y int) (pane, bool) { + for _, region := range m.paneRegions() { + if region.contains(x, y) { + return region.pane, true + } + } + return 0, false +} + +// regionFor returns the drawn region of a pane. +func (m *Model) regionFor(target pane) (paneRegion, bool) { + for _, region := range m.paneRegions() { + if region.pane == target { + return region, true + } + } + return paneRegion{}, false +} + +// handleMouse routes a mouse event. +// +// While the composer is open the mouse is ignored rather than acted on: the form is modal, and +// clicking a pane behind it would move focus somewhere the user cannot see. +func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) { + if m.composer != nil { + return m, nil + } + + mouse := msg.Mouse() + switch msg.(type) { + case tea.MouseWheelMsg: + return m.wheel(mouse) + case tea.MouseClickMsg: + if mouse.Button != tea.MouseLeft { + // Only the left button acts. A middle-click paste or a right-click menu would be + // surprising in a tool that has neither. + return m, nil + } + return m.click(mouse) + } + return m, nil +} + +// wheel scrolls whichever pane the pointer is over, without moving focus. +// +// Scrolling under the pointer rather than in the focused pane is what a terminal user expects, +// and it means the wheel can be used to glance at the log without giving up the prompt. +func (m *Model) wheel(mouse tea.Mouse) (tea.Model, tea.Cmd) { + target, ok := m.paneAt(mouse.X, mouse.Y) + if !ok { + return m, nil + } + + delta := 3 + if mouse.Button == tea.MouseWheelUp { + delta = -3 + } + + switch target { + case paneSidebar: + for range 3 { + if delta < 0 { + m.moveSidebar(-1) + } else { + m.moveSidebar(1) + } + } + case paneMessages: + if delta < 0 { + m.messages.MoveUp(3) + } else { + m.messages.MoveDown(3) + } + m.syncDetail() + case paneDetail: + m.detail.SetYOffset(max(m.detail.YOffset()+delta, 0)) + case paneLog: + m.logView.SetYOffset(max(m.logView.YOffset()+delta, 0)) + } + return m, nil +} + +// click gives a pane the keyboard and, where the pane has rows, selects the row clicked. +func (m *Model) click(mouse tea.Mouse) (tea.Model, tea.Cmd) { + target, ok := m.paneAt(mouse.X, mouse.Y) + if !ok { + // A click on the prompt row, the toast or the help footer returns to the prompt, which + // is the least surprising thing a click outside the panes can do. + m.focusPrompt() + return m, nil + } + + m.blurPrompt() + m.setFocus(target) + + region, found := m.regionFor(target) + if !found { + return m, nil + } + // One row of border at the top, and for the message table one further row of column + // headings. + row := mouse.Y - region.y0 - 1 + + switch target { + case paneSidebar: + m.selectSidebarRow(row) + case paneMessages: + if row >= 1 { + m.selectMessageRow(row - 1) + } + } + return m, nil +} + +// selectSidebarRow moves the sidebar cursor to a drawn row, ignoring headings and blanks. +// +// The sidebar's drawn lines do not map one-to-one onto its rows, because a section heading is +// preceded by a blank line. Walking the same construction the renderer uses keeps the two in +// step rather than duplicating the arithmetic. +func (m *Model) selectSidebarRow(drawn int) { + line := 0 + for i, row := range m.sidebarRows { + if row.heading != "" { + if line > 0 { + line++ // the blank line the renderer inserts before a heading + } + if line == drawn { + return // a heading is not selectable + } + line++ + continue + } + if line == drawn { + m.sidebarIndex = i + return + } + line++ + } +} + +// selectMessageRow moves the table cursor to a drawn body row. +// +// bubbles/table keeps its scroll offset private, so the clicked row cannot be derived from the +// cursor and the height -- trying that is the same mistake as deriving a scroll window, and it +// selects the wrong row as soon as the table has scrolled. Instead the row is read off the +// screen: the first column carries the message's own number, which is exactly the information +// needed and is by construction what the user clicked. +func (m *Model) selectMessageRow(drawn int) { + if drawn < 0 { + return + } + lines := splitLines(m.messages.View()) + // One line of column headings precedes the body. + index := drawn + 1 + if index < 0 || index >= len(lines) { + return + } + number, ok := leadingNumber(lines[index]) + if !ok { + return + } + // The column is 1-based, matching what the pane shows. + m.messages.SetCursor(number - 1) + m.syncDetail() +} + +// leadingNumber reads the first run of digits from a rendered row, skipping any styling. +func leadingNumber(line string) (int, bool) { + digits := make([]rune, 0, 8) + seen := false + for _, r := range stripANSI(line) { + switch { + case r >= '0' && r <= '9': + digits = append(digits, r) + seen = true + case seen: + // The number has ended. + value, err := strconv.Atoi(string(digits)) + return value, err == nil + } + } + if !seen { + return 0, false + } + value, err := strconv.Atoi(string(digits)) + return value, err == nil +} + +// stripANSI removes escape sequences so a rendered line can be read as text. +func stripANSI(s string) string { + var out strings.Builder + inEscape := false + for _, r := range s { + switch { + case r == 0x1b: + inEscape = true + case inEscape && (r == 'm' || r == 'K' || r == 'H'): + inEscape = false + case !inEscape: + out.WriteRune(r) + } + } + return out.String() +} diff --git a/plc4go/tools/plc4xbrowser/browser/mouse_test.go b/plc4go/tools/plc4xbrowser/browser/mouse_test.go new file mode 100644 index 000000000..5553157bd --- /dev/null +++ b/plc4go/tools/plc4xbrowser/browser/mouse_test.go @@ -0,0 +1,221 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package browser + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/plcsession" + "github.com/apache/plc4x-extras/plc4go/tools/internal/tui" +) + +// The tview interface this replaced called EnableMouse(true), so clicking and scrolling both +// worked. These tests exist because dropping that was a silent capability regression, and +// nothing else would have caught it. + +// clickAt sends a left click at a cell. +func clickAt(model *Model, x, y int) { + model.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft}) +} + +// TestTheMouseIsEnabled is the direct regression test: the port must ask the terminal for +// mouse events at all. +func TestTheMouseIsEnabled(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + assert.Equal(t, tea.MouseModeCellMotion, model.View().MouseMode, + "the tview interface enabled the mouse, so this one must too") +} + +// TestClickingAPaneGivesItTheKeyboard is the behaviour a user notices first. +func TestClickingAPaneGivesItTheKeyboard(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + require.True(t, mustPromptFocused(model), "focus starts at the prompt") + + for _, target := range []pane{paneSidebar, paneMessages, paneDetail, paneLog} { + t.Run(target.title(), func(t *testing.T) { + region, ok := model.regionFor(target) + require.True(t, ok, "%s must be drawn at this size", target.title()) + + // Click the middle of the pane. + clickAt(model, (region.x0+region.x1)/2, (region.y0+region.y1)/2) + + focus, promptFocused := model.Focused() + assert.False(t, promptFocused, "clicking a pane must take the keyboard from the prompt") + assert.Equal(t, target, focus, "the clicked pane must be the focused one") + }) + } +} + +// TestClickingOutsideThePanesReturnsToThePrompt keeps a stray click from stranding the user. +func TestClickingOutsideThePanesReturnsToThePrompt(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + // Take focus off the prompt first. + region, ok := model.regionFor(paneMessages) + require.True(t, ok) + clickAt(model, region.x0+2, region.y0+2) + require.False(t, mustPromptFocused(model)) + + // The prompt row is the second from the bottom. + clickAt(model, 5, 29-1) + assert.True(t, mustPromptFocused(model), "clicking the prompt row must return the keyboard to it") +} + +// TestClickingAMessageRowSelectsIt covers the click-to-select the old lists provided. +func TestClickingAMessageRowSelectsIt(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + // Several separate reads: a browse is one message, so it would give only one row. + for range 5 { + _, cmd := model.Update(tui.PromptSubmitMsg{Line: "read-direct " + plcsession.DemoDeviceOne + " temp/1"}) + runCmd(t, model, cmd) + } + shown, _ := model.EventCount() + require.Greater(t, shown, 2, "several reads should give several rows") + + region, ok := model.regionFor(paneMessages) + require.True(t, ok) + + // Row 0 of the body: one row of border, one row of column headings. + clickAt(model, region.x0+3, region.y0+2) + first := model.messages.Cursor() + + clickAt(model, region.x0+3, region.y0+3) + second := model.messages.Cursor() + + assert.Equal(t, first+1, second, "clicking one row lower must select the next message") +} + +// TestClickingASidebarRowSelectsItAndSkipsHeadings pins that a heading is not selectable, since +// the sidebar's drawn lines do not map one-to-one onto its rows. +func TestClickingASidebarRowSelectsItAndSkipsHeadings(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + region, ok := model.regionFor(paneSidebar) + require.True(t, ok) + + // Line 0 of the body is the CONNECTIONS heading; line 1 is the first connection. + clickAt(model, region.x0+2, region.y0+2) + row, ok := model.SidebarSelection() + require.True(t, ok) + assert.True(t, row.selectable(), "the cursor must never land on a heading") + assert.NotEmpty(t, row.connection, "the first selectable row is the demo connection") +} + +// TestTheWheelScrollsTheePaneUnderThePointerWithoutTakingFocus is what makes the wheel useful: +// glancing at the log must not cost the prompt. +func TestTheWheelScrollsThePaneUnderThePointerWithoutTakingFocus(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + // Fill the log so there is something to scroll. + for i := range 60 { + model.AppendLog("line " + string(rune('a'+i%26))) + } + require.True(t, mustPromptFocused(model)) + + region, ok := model.regionFor(paneLog) + require.True(t, ok) + before := model.logView.YOffset() + + model.Update(tea.MouseWheelMsg{X: region.x0 + 4, Y: region.y0 + 1, Button: tea.MouseWheelUp}) + assert.NotEqual(t, before, model.logView.YOffset(), "the wheel must scroll the pane under the pointer") + assert.True(t, mustPromptFocused(model), "scrolling must not steal the keyboard from the prompt") +} + +func TestTheWheelMovesTheMessageCursor(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + // Several separate reads: a browse is one message now, so it gives nothing to scroll. + for range 5 { + _, cmd := model.Update(tui.PromptSubmitMsg{Line: "read-direct " + plcsession.DemoDeviceOne + " temp/1"}) + runCmd(t, model, cmd) + } + + region, ok := model.regionFor(paneMessages) + require.True(t, ok) + before := model.messages.Cursor() + + model.Update(tea.MouseWheelMsg{X: region.x0 + 4, Y: region.y0 + 3, Button: tea.MouseWheelUp}) + assert.Less(t, model.messages.Cursor(), before, "the wheel must move the selection up") +} + +// TestTheMouseIsIgnoredWhileTheComposerIsOpen: the form is modal, so a click on a pane behind +// it would move focus somewhere the user cannot see. +func TestTheMouseIsIgnoredWhileTheComposerIsOpen(t *testing.T) { + model := sized(t, newTestModel(t), 120, 30) + _, cmd := model.Update(tui.PromptSubmitMsg{Line: "read " + plcsession.DemoDeviceOne}) + runCmd(t, model, cmd) + require.True(t, model.ComposerOpen()) + + region, ok := model.regionFor(paneLog) + require.True(t, ok) + clickAt(model, region.x0+4, region.y0+1) + + assert.True(t, model.ComposerOpen(), "the form must stay open") + focus, _ := model.Focused() + assert.NotEqual(t, paneLog, focus, "a click behind a modal form must not move focus") +} + +// TestPaneRegionsTileTheBodyWithoutOverlapping guards the coordinate mapping: two panes +// claiming the same cell would make clicks land unpredictably. +func TestPaneRegionsTileTheBodyWithoutOverlapping(t *testing.T) { + for _, size := range []tui.Size{ + {Width: 120, Height: 40}, + {Width: 100, Height: 30}, + {Width: 100, Height: 24}, + {Width: 80, Height: 24}, + } { + t.Run(size.String(), func(t *testing.T) { + model := sized(t, newTestModel(t), size.Width, size.Height) + regions := model.paneRegions() + require.NotEmpty(t, regions) + + for i, a := range regions { + for j, b := range regions { + if i >= j { + continue + } + overlap := a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 + assert.False(t, overlap, + "%s and %s overlap: %v vs %v", a.pane.title(), b.pane.title(), a, b) + } + } + }) + } +} + +// TestNoPaneRegionCoversThePromptRow is the mouse half of "the prompt is always reachable": a +// pane claiming the prompt's row would swallow clicks meant for it. +func TestNoPaneRegionCoversThePromptRow(t *testing.T) { + for _, size := range []tui.Size{ + {Width: 120, Height: 40}, + {Width: 100, Height: 24}, + {Width: 80, Height: 24}, + {Width: 60, Height: 12}, + } { + t.Run(size.String(), func(t *testing.T) { + model := sized(t, newTestModel(t), size.Width, size.Height) + promptRow := size.Height - 2 + for _, region := range model.paneRegions() { + assert.False(t, region.contains(1, promptRow), + "%s covers the prompt row %d", region.pane.title(), promptRow) + } + }) + } +} diff --git a/plc4go/tools/plc4xbrowser/browser/run.go b/plc4go/tools/plc4xbrowser/browser/run.go new file mode 100644 index 000000000..34238de61 --- /dev/null +++ b/plc4go/tools/plc4xbrowser/browser/run.go @@ -0,0 +1,174 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package browser + +import ( + "context" + + tea "charm.land/bubbletea/v2" + "github.com/apache/plc4x/plc4go/spi/errors" + "github.com/charmbracelet/fang" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/plcsession" +) + +// The command line. +// +// plc4xbrowser previously had none at all: main called straight into the tview application, so +// there was nowhere to put a flag. It gains a cobra root here, wrapped in fang for the same +// styled help and errors the pcap analyser now uses. + +// Settings are the command-line settings. +type Settings struct { + // Demo runs against the simulated devices instead of real hardware. + Demo bool + // ASCII forces the ASCII glyph set for terminals that cannot render box-drawing characters. + ASCII bool + // LogLevel overrides the persisted log level. + LogLevel string + // Version labels the status bar. + Version string +} + +// Execute runs the browser's command line. +// +// WithoutVersion is deliberate: fang offers a version flag, and adding one is out of scope, so +// it is suppressed rather than acquired as a side effect of adopting fang. +func Execute(version string) error { + settings := Settings{Version: version} + root := NewRootCommand(&settings) + return fang.Execute(context.Background(), root, fang.WithoutVersion()) +} + +// NewRootCommand builds the cobra root. +func NewRootCommand(settings *Settings) *cobra.Command { + root := &cobra.Command{ + Use: "plc4xbrowser", + Short: "Browse and exercise PLC connections from the terminal", + Long: "plc4xbrowser connects to PLCs through plc4x and lets you discover devices, browse\n" + + "their tags, and read, write and subscribe to them from a terminal interface.\n\n" + + "Run it with --demo to try it against simulated devices, with no hardware attached.", + Example: " plc4xbrowser --demo\n" + + " plc4xbrowser --log-level debug\n" + + " plc4xbrowser --ascii", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, _ []string) error { + return Run(cmd.Context(), *settings) + }, + } + root.Flags().BoolVar(&settings.Demo, "demo", false, + "run against simulated devices, for demos and manual debugging with no hardware attached") + root.Flags().BoolVar(&settings.ASCII, "ascii", false, + "force ASCII drawing characters instead of Unicode, for terminals that cannot render them") + root.Flags().StringVar(&settings.LogLevel, "log-level", "", + "log level (trace, debug, info, warn, error); defaults to the saved setting") + return root +} + +// Run starts the terminal interface. +func Run(ctx context.Context, settings Settings) error { + if ctx == nil { + ctx = context.Background() + } + + config := NewConfig() + if path, err := ConfigPath(); err == nil { + if loaded, loadErr := LoadConfig(path); loadErr == nil { + config = loaded + } + } + if settings.LogLevel != "" { + config.LogLevel = settings.LogLevel + } + applyLogLevel(config.LogLevel) + + session, frames, demo, err := newSession(ctx, settings, &config) + if err != nil { + return err + } + + model := NewModel(Options{ + Session: session, + Config: &config, + Demo: demo, + Version: settings.Version, + ForceASCII: settings.ASCII, + Frames: frames, + }) + if demo { + model.AppendLog("demo mode: simulated devices, no hardware attached") + model.AppendLog("connected " + plcsession.DemoDeviceOne + " — try: browse-direct " + plcsession.DemoDeviceOne) + } + + program := tea.NewProgram(model, tea.WithContext(ctx)) + _, err = program.Run() + return err +} + +// newSession builds the session the interface runs on. +// +// Demo mode swaps this one value and nothing else: the model, the commands and the tests all +// run against the same seam, so demo mode cannot drift away from the real thing. +func newSession(ctx context.Context, settings Settings, config *Config) (plcsession.Session, *plcsession.FrameLog, bool, error) { + if settings.Demo { + session := plcsession.NewDemo(plcsession.DemoOptions{}) + if _, err := session.RegisterDriver(plcsession.DemoProtocol); err != nil { + return nil, nil, false, errors.Wrap(err, "error preparing the demo session") + } + // Open a connection at startup so the tool has something to show immediately; a demo + // that opens on an empty screen demonstrates nothing. + if _, err := session.Connect(ctx, plcsession.DemoDeviceOne); err != nil { + return nil, nil, false, errors.Wrap(err, "error opening the demo connection") + } + // No frame log: a simulated device puts nothing on a wire, and inventing bytes for the + // byte view would be worse than the view saying there are none. + return session, nil, true, nil + } + + frames := plcsession.NewFrameLog(0) + session := plcsession.NewLive(plcsession.LiveOptions{Frames: frames}) + for _, protocol := range config.AutoRegisterDrivers { + if _, err := session.RegisterDriver(protocol); err != nil { + log.Warn().Err(err).Str("protocol", protocol).Msg("Could not auto-register driver") + } + } + return session, frames, false, nil +} + +// applyLogLevel sets the global log level, falling back to info rather than exiting. +// +// The pcap analyser's equivalent called log.Fatal on an unparseable level, which kills the +// process over a typo in a configuration file. A bad level here is worth a warning, not a +// refusal to start. +func applyLogLevel(level string) { + if level == "" { + zerolog.SetGlobalLevel(zerolog.InfoLevel) + return + } + parsed, err := zerolog.ParseLevel(level) + if err != nil { + log.Warn().Str("level", level).Msg("Unknown log level, using info") + parsed = zerolog.InfoLevel + } + zerolog.SetGlobalLevel(parsed) +} diff --git a/plc4go/tools/plc4xbrowser/browser/view.go b/plc4go/tools/plc4xbrowser/browser/view.go new file mode 100644 index 000000000..1135db126 --- /dev/null +++ b/plc4go/tools/plc4xbrowser/browser/view.go @@ -0,0 +1,767 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package browser + +import ( + "fmt" + "strconv" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/plcsession" + "github.com/apache/plc4x-extras/plc4go/tools/internal/tui" +) + +// Rendering. +// +// Every line the model emits is exactly the terminal width, and the last three rows -- toast, +// prompt and help -- are always present. That is not stylistic: below 100 columns the previous +// implementation added its command area to the grid with a zero row and column span, which the +// grid silently discarded, so the input field was never drawn and the user typed into +// something invisible. + +// View renders the whole screen. +func (m *Model) View() tea.View { + view := tea.NewView(m.render()) + view.AltScreen = true + // The tview interface this replaces called EnableMouse(true), so clicking and scrolling + // worked; leaving it off would have been a silent capability regression. CellMotion rather + // than AllMotion: it covers clicks and the wheel, is more widely supported, and avoids the + // stream of motion events that AllMotion produces for no benefit here. + view.MouseMode = tea.MouseModeCellMotion + return view +} + +// render builds the screen content. +func (m *Model) render() string { + if !m.ready { + return "" + } + width, height := m.layout.Size.Width, m.layout.Size.Height + + if m.layout.Mode == tui.ModeTooSmall { + return m.renderTooSmall(width, height) + } + + var rows []string + rows = append(rows, m.renderStatus(width)) + rows = append(rows, m.renderBody()...) + if m.layout.ShowLog { + rows = append(rows, splitLines(m.renderLogPane(width))...) + } + rows = append(rows, m.renderToast(width)) + rows = append(rows, m.renderPrompt(width)) + rows = append(rows, m.renderHelp(width)) + rows = fitScreen(rows, width, height) + + // Both overlays float over the panes rather than taking rows of their own. lipgloss v2 has + // a real compositor, so neither is spliced into strings by hand, and neither can push the + // prompt or the help footer off the bottom of the screen. + if completion := m.prompt.CompletionView(completionRows); completion != "" { + rows = m.overlayCompletion(rows, completion, width, height) + } + if m.composer != nil { + rows = m.overlayComposer(rows, width, height) + } + + return strings.Join(fitScreen(rows, width, height), "\n") +} + +// renderTooSmall says what the tool needs rather than drawing something broken. +func (m *Model) renderTooSmall(width, height int) string { + message := tui.TooSmallMessage() + " (" + strconv.Itoa(tui.MinWidth) + "x" + strconv.Itoa(tui.MinHeight) + " minimum)" + lines := make([]string, 0, height) + for i := range height { + if i == height/2 { + lines = append(lines, centre(m.theme.Err.Render(message), width)) + continue + } + lines = append(lines, strings.Repeat(" ", width)) + } + return strings.Join(lines, "\n") +} + +// renderStatus draws the top line: what the tool is, whether it is simulated, and the counts. +// +// It degrades rather than overflowing. At the widths this tool has to work at, the full line +// does not fit -- the approved design is about 91 cells wide and the target is 80 -- so pieces +// are dropped in order of how little they are missed. +func (m *Model) renderStatus(width int) string { + // The row is a band, so it is drawn with the surfaced theme throughout: the top bar and the + // command bar at the foot then frame the panes between them. + theme := m.theme.Surfaced() + glyphs := theme.Glyphs + + name := "PLC4X Browser" + if m.options.Version != "" { + name += " " + m.options.Version + } + shown, total := m.EventCount() + // The numbers carry the accent and their labels stay muted, so the eye lands on the counts + // rather than on the words between them. + count := func(label string, value int) string { + return theme.Muted.Render(label+" ") + theme.Accent.Render(strconv.Itoa(value)) + } + counts := strings.Join([]string{ + count("conn", len(m.options.Session.Connections())), + count("drv", len(m.options.Session.Drivers())), + count("msg", total), + }, theme.Chrome.Render(" "+glyphs.Separator+" ")) + if shown != total { + counts += theme.Muted.Render(" (" + strconv.Itoa(shown) + " shown)") + } + // A count of messages says nothing about whether any of them failed, which is the one + // number worth glancing at, so it is the one that is red. + if failed := m.FailedCount(); failed > 0 { + counts += theme.Chrome.Render(" "+glyphs.Separator+" ") + + theme.Err.Render(strconv.Itoa(failed)+" failed") + } + logLevel := m.options.Config.LogLevel + if logLevel == "" { + logLevel = "info" + } + + badge := "" + if m.options.Demo { + badge = theme.Badge.Render("DEMO") + } + + // Widest first; each candidate drops the least useful remaining piece. + focusName := "pane " + m.focusName() + candidates := [][]string{ + {name, badge, counts, "log " + logLevel, focusName}, + {name, badge, counts, "log " + logLevel}, + {name, badge, counts}, + {name, badge}, + {name}, + } + // The spaces are inside the styled run, not outside it. A terminal ends a background at + // the style's reset, so a bare space between two styled pieces would show the terminal's + // own background through the band and stripe the bar. + separator := theme.Muted.Render(" " + glyphs.Separator + " ") + for _, candidate := range candidates { + parts := make([]string, 0, len(candidate)) + for i, part := range candidate { + if part == "" { + continue + } + if i == 0 { + parts = append(parts, theme.Title.Render(part)) + continue + } + if part == badge { + parts = append(parts, part) + continue + } + parts = append(parts, theme.Muted.Render(part)) + } + line := theme.Surface.Render(" ") + strings.Join(parts, separator) + if lipgloss.Width(line) <= width { + return padOnto(theme.Surface, line, width) + } + } + return padTo(" "+theme.Title.Render(name), width) +} + +// paneTitle prefixes a pane's name with the key that jumps to it, the way btop does. +// +// The hotkeys were unreachable in practice for two reasons: alt+digit is swallowed by every +// terminal that binds it to tab switching, and nothing on screen said which number belonged to +// which pane. Drawing the number fixes the second half; accepting a bare digit wherever it +// cannot be text fixes the first. +func paneTitle(p pane) string { + return "[" + strconv.Itoa(int(p)+1) + "] " + p.title() +} + +// focusName names the region holding the keyboard. +func (m *Model) focusName() string { + if m.promptFocused { + return "prompt" + } + return strings.ToLower(m.focus.title()) +} + +// renderBody draws the pane area for the current layout regime. +func (m *Model) renderBody() []string { + if m.layout.Mode == tui.ModeCompact { + return splitLines(m.renderMessagesPane(m.layout.Size.Width, m.layout.BodyHeight)) + } + + sidebar := m.renderSidebarPane(m.layout.SidebarWidth, m.layout.BodyHeight) + main := m.renderMainColumn(m.layout.MainWidth, m.layout.BodyHeight) + if !m.layout.ShowDetail { + return splitLines(lipgloss.JoinHorizontal(lipgloss.Top, sidebar, main)) + } + detail := m.renderDetailPane(m.layout.DetailWidth, m.layout.BodyHeight) + return splitLines(lipgloss.JoinHorizontal(lipgloss.Top, sidebar, main, detail)) +} + +// renderMainColumn draws the message table. It is a seam kept for the layout regimes: every +// current one puts the detail beside the messages rather than beneath them. +func (m *Model) renderMainColumn(width, height int) string { + return m.renderMessagesPane(width, height) +} + +// renderSidebarPane draws the connections and drivers. +func (m *Model) renderSidebarPane(width, height int) string { + theme := m.theme + glyphs := theme.Glyphs + inner := max(width-2, 1) + + var lines []string + for i, row := range m.sidebarRows { + switch { + case row.heading != "": + if len(lines) > 0 { + lines = append(lines, "") + } + lines = append(lines, theme.Key.Render(row.heading)) + default: + marker := " " + if i == m.sidebarIndex && m.focus == paneSidebar && !m.promptFocused { + marker = glyphs.Selected + } + label := row.label + if row.detail != "" { + label += " " + theme.Muted.Render(row.detail) + } + text := marker + " " + label + if row.marker != "" { + text = fitInline(text, inner-2) + " " + row.marker + } + if i == m.sidebarIndex && m.focus == paneSidebar && !m.promptFocused { + text = theme.SelectedRow.Render(marker + " " + row.label) + if row.marker != "" { + text = fitInline(text, inner-2) + " " + row.marker + } + } + lines = append(lines, text) + } + } + + // The pane had eleven blank rows at the reference size with one connection and one driver. + // The last browse goes there as a catalogue you can refer back to while reading individual + // tags -- not the selected message's tags, which the detail pane is already showing. + // Suppressed while the detail pane is already showing this very list: the catalogue earns + // its space as a reference to refer back to once a single tag is being read, not as a + // second copy of what is on screen beside it. + if len(m.catalogue) > 0 && !m.detailShowsCatalogue() { + lines = append(lines, "", theme.Key.Render("TAGS "+m.catalogueConnection)) + for _, tag := range m.catalogue { + label := " " + tag.Address + if tag.DataType != "" { + label += " " + theme.DataType(tag.DataType).Render(tag.DataType) + } + lines = append(lines, fitInline(label, inner)) + } + } + + status := strconv.Itoa(len(m.options.Session.Connections())) + pane := tui.Pane{ + Title: paneTitle(paneSidebar), + Status: status, + Width: width, + Height: height, + Focused: m.focus == paneSidebar && !m.promptFocused, + } + return pane.Render(theme, strings.Join(lines, "\n")) +} + +// renderMessagesPane draws the message table. +func (m *Model) renderMessagesPane(width, height int) string { + theme := m.theme + shown, total := m.EventCount() + + status := strconv.Itoa(total) + if shown != total { + status = strconv.Itoa(shown) + " of " + strconv.Itoa(total) + } + if m.filter != "" { + status = "/" + m.filter + " " + status + } + + body := m.messages.View() + if total == 0 { + // An empty pane that names the next command beats an empty pane. + body = theme.Muted.Render("no messages yet") + "\n\n" + + theme.Muted.Render("try: read-direct ") + "\n" + + theme.Muted.Render(" or: browse-direct ") + } + + pane := tui.Pane{ + Title: paneTitle(paneMessages), + Status: status, + Width: width, + Height: height, + Focused: m.focus == paneMessages && !m.promptFocused, + } + return pane.Render(theme, body) +} + +// renderDetailPane draws the selected message. +func (m *Model) renderDetailPane(width, height int) string { + theme := m.theme + status := "" + body := theme.Muted.Render("select a message") + if event, ok := m.SelectedEvent(); ok { + status = string(event.Kind) + body = m.detail.View() + } + pane := tui.Pane{ + Title: paneTitle(paneDetail), + Status: status, + Width: width, + Height: height, + Focused: m.focus == paneDetail && !m.promptFocused, + } + return pane.Render(theme, body) +} + +// renderLogPane draws the console drawer. +func (m *Model) renderLogPane(width int) string { + theme := m.theme + level := m.options.Config.LogLevel + if level == "" { + level = "info" + } + body := m.logView.View() + if len(m.logLines) == 0 { + body = theme.Muted.Render("no output yet") + } + pane := tui.Pane{ + Title: paneTitle(paneLog) + " " + theme.Glyphs.Separator + " " + level, + Status: strconv.Itoa(len(m.logLines)), + Width: width, + Height: m.layout.LogHeight, + Focused: m.focus == paneLog && !m.promptFocused, + } + return pane.Render(theme, body) +} + +// renderToast draws the quit question, the held error line, or an empty row to keep the +// geometry stable. +func (m *Model) renderToast(width int) string { + if m.confirmQuit { + return m.renderConfirmQuit(width) + } + if m.toast == "" { + return strings.Repeat(" ", width) + } + theme := m.theme + hint := theme.Muted.Render("esc dismiss") + message := theme.Err.Render(theme.Glyphs.Err + " " + m.toast) + line := " " + message + // Right-align the hint when it fits; drop it rather than wrap when it does not. + if gap := width - lipgloss.Width(line) - lipgloss.Width(hint) - 1; gap > 0 { + line += strings.Repeat(" ", gap) + hint + } + return fitInline(line, width) +} + +// renderConfirmQuit asks the question, and says what will and will not answer it. +// +// It sits in the row the toast would use, directly above the prompt, so the question is next to +// the keyboard rather than somewhere the eye has to find. +func (m *Model) renderConfirmQuit(width int) string { + theme := m.theme + line := " " + theme.Warn.Render(theme.Glyphs.Warn+" quit?") + " " + + theme.Muted.Render("open connections will be closed") + hint := theme.Accent.Render("y") + theme.Muted.Render(" or ") + + theme.Accent.Render("ctrl+c") + theme.Muted.Render(" quit") + + theme.Chrome.Render(" "+theme.Glyphs.Separator+" ") + + theme.Accent.Render("any other key") + theme.Muted.Render(" stay") + if gap := width - lipgloss.Width(line) - lipgloss.Width(hint) - 1; gap > 0 { + line += strings.Repeat(" ", gap) + hint + } + return fitInline(line, width) +} + +// renderPrompt draws the command line. +// +// It is emitted after everything droppable and before the help footer, so no layout decision +// can remove it. +func (m *Model) renderPrompt(width int) string { + return fitInline(m.theme.Surface.Render(" ")+m.prompt.View(), width) +} + +// overlayCompletion floats the completion list directly above the prompt. +func (m *Model) overlayCompletion(rows []string, completion string, width, height int) []string { + lines := splitLines(completion) + // Anchor it so its last line sits just above the prompt, which is the second-to-last row, + // and cover whole rows: a partial-width overlay lets the pane underneath show through at + // the margins, which reads as a corrupted screen rather than as a popup. + y := max(height-2-len(lines), 0) + + base := lipgloss.NewLayer(strings.Join(rows, "\n")) + overlay := lipgloss.NewLayer(m.opaqueBlock(indentBlock(completion, 3), width)).X(0).Y(y).Z(1) + return splitLines(lipgloss.NewCompositor(base, overlay).Render()) +} + +// opaqueBlock squares a block off to a single rectangle, clipped to a maximum width. +// +// A floating list has to be opaque. Left ragged, the pane underneath shows through the short +// lines and the result reads as a corrupted screen rather than as a popup. +func (m *Model) opaqueBlock(block string, maxWidth int) string { + lines := splitLines(block) + for i, line := range lines { + lines[i] = fitInline(line, maxWidth) + } + return strings.Join(lines, "\n") +} + +// indentBlock shifts every line of a block right, so the completion list sits under the text +// of the prompt rather than under its "$" marker. +func indentBlock(block string, by int) string { + prefix := strings.Repeat(" ", by) + lines := splitLines(block) + for i, line := range lines { + lines[i] = prefix + line + } + return strings.Join(lines, "\n") +} + +// completionRows bounds the completion popup so it cannot push the prompt off the screen. +const completionRows = 4 + +// renderHelp draws the key hints. +// +// While the quit question is up the footer says what answers it and nothing else, because every +// other binding is inert until the question is resolved and a footer listing them would be the +// same lie the help used to tell about keys that were never implemented. +func (m *Model) renderHelp(width int) string { + if m.confirmQuit { + theme := m.theme + return fitInline(" "+theme.Accent.Render("y")+" "+theme.Muted.Render("quit")+ + theme.Chrome.Render(" "+theme.Glyphs.Separator+" ")+ + theme.Accent.Render("esc")+" "+theme.Muted.Render("stay"), width) + } + return fitInline(" "+m.help.View(m.keys.ForFocus(m.currentFocus())), width) +} + +// overlayComposer composites the modal form over the pane area. +func (m *Model) overlayComposer(rows []string, width, height int) []string { + form := m.renderComposer(width, height) + formWidth := lipgloss.Width(strings.SplitN(form, "\n", 2)[0]) + formHeight := len(splitLines(form)) + + // Centre it over the panes, never over the prompt: the form is modal but the command line + // stays visible so the user can see what they were doing. + x := max((width-formWidth)/2, 0) + y := max((height-tui.ReservedRows-formHeight)/2, 1) + + base := lipgloss.NewLayer(strings.Join(rows, "\n")) + overlay := lipgloss.NewLayer(form).X(x).Y(y).Z(1) + return splitLines(lipgloss.NewCompositor(base, overlay).Render()) +} + +// renderEventDetail renders one message for the detail pane. +func renderEventDetail(theme tui.Theme, event plcsession.Event, width int) string { + glyphs := theme.Glyphs + var lines []string + + // The header carries the operation and the connection. The old body then repeated both in + // a summary line that the log pane already showed verbatim, so three places said the same + // thing and none of them said anything new. + header := theme.Value.Render(string(event.Kind)) + if event.Connection != "" { + header += " " + theme.Muted.Render(event.Connection) + } + lines = append(lines, header) + + timing := event.Received.Format("15:04:05.000") + if !event.Started.IsZero() { + if took := event.Received.Sub(event.Started); took > 0 { + timing += " " + glyphs.Separator + " " + roundDuration(took) + } + } + if len(event.Tags) > 0 { + timing += " " + glyphs.Separator + " " + strconv.Itoa(len(event.Tags)) + " tags" + } + lines = append(lines, theme.Muted.Render(timing)) + + // A summary only when it adds something the tags do not, which for a browse is nothing. + if event.Summary != "" && len(event.Tags) == 0 { + lines = append(lines, "", theme.Muted.Render(event.Summary)) + } + + if len(event.Tags) > 0 { + lines = append(lines, "") + for _, tag := range event.Tags { + marker := theme.Ok.Render(glyphs.Ok) + if !tag.Succeeded() { + marker = theme.Err.Render(glyphs.Err) + } + line := marker + " " + theme.Value.Render(tag.Address) + if tag.DataType != "" { + // The type carries its family's hue: "which of these are numbers" is a + // question colour answers faster than reading eight type names. + line += " " + theme.DataType(tag.DataType).Render(tag.DataType) + } + if tag.Value != "" { + line += " " + renderTagValue(theme, tag) + } + if !tag.Succeeded() { + line += " " + theme.Err.Render(tag.Code) + } + lines = append(lines, line) + } + lines = append(lines, "") + lines = append(lines, detailHint(theme, width)...) + } + return strings.Join(lines, "\n") +} + +// detailActions are the actions the Detail pane advertises on its own hint line. +// +// A value rather than a literal string, so that a test can walk exactly what the pane draws. +// This line used to be a literal while r, w and s were bound only at Messages focus, so the +// pane advertised three keys that did nothing in the pane doing the advertising -- and the +// existing guard against dead affordances only walked the footer, which does not carry them. +var detailActions = []struct { + Key string + Label string +}{ + {Key: "r", Label: "read"}, + {Key: "w", Label: "write"}, + {Key: "s", Label: "subscribe"}, + {Key: "y", Label: "yank"}, + // b belongs here rather than in the shared footer. The shared bindings are scoped to pane + // focus as a whole, so advertising the byte view there would advertise it while the sidebar + // or the log has the keyboard, where it does nothing. This line is per-pane by construction. + {Key: "b", Label: "bytes"}, +} + +// detailHint renders the Detail pane's action line, wrapped to width. +// +// Wrapped rather than clipped: the pane is the narrowest of the three, and at eighty columns the +// single line lost its last action half way through a word -- "y yan". An advertisement that +// arrives truncated is worse than one that takes a second row. +func detailHint(theme tui.Theme, width int) []string { + separator := " " + theme.Glyphs.Separator + " " + var lines []string + current := "" + for _, action := range detailActions { + part := action.Key + " " + action.Label + switch { + case current == "": + current = part + case lipgloss.Width(current+separator+part) <= width: + current += separator + part + default: + lines = append(lines, theme.Muted.Render(current)) + current = part + } + } + if current != "" { + lines = append(lines, theme.Muted.Render(current)) + } + return lines +} + +// renderTagValue styles a tag's value. +// +// A browse puts the access flags in the value, and those read better split: the letters say +// what may be done with the tag, the rest is its human name. +func renderTagValue(theme tui.Theme, tag plcsession.TagResult) string { + value := tag.Value + flags, name, isAccess := strings.Cut(value, " ") + if isAccess && isAccessFlags(flags) { + return theme.Accent.Render(flags) + " " + theme.Muted.Render(name) + } + if isAccessFlags(value) { + return theme.Accent.Render(value) + } + return theme.DataType(tag.DataType).Render(value) +} + +// isAccessFlags reports whether text is an rws access triple, so a value that merely happens +// to be three characters long is not mistaken for one. +func isAccessFlags(text string) bool { + if len(text) != 3 { + return false + } + for i, want := range []byte{'r', 'w', 's'} { + if text[i] != want && text[i] != '-' { + return false + } + } + return true +} + +// roundDuration renders a duration at a precision worth reading. A synthetic read reporting +// 62.25 microseconds is noise, not information. +func roundDuration(d time.Duration) string { + switch { + case d >= time.Second: + return d.Round(10 * time.Millisecond).String() + case d >= time.Millisecond: + return d.Round(100 * time.Microsecond).String() + default: + return d.Round(time.Microsecond).String() + } +} + +// fitScreen forces the rendered rows into exactly height lines of exactly width cells. +// +// This is the last line of defence: a pane that miscounts by one cell would otherwise tear +// every row beneath it. +func fitScreen(rows []string, width, height int) []string { + out := make([]string, 0, height) + for i := range height { + if i < len(rows) { + out = append(out, fitInline(rows[i], width)) + continue + } + out = append(out, strings.Repeat(" ", width)) + } + return out +} + +// fitInline pads or truncates one line to exactly width display cells. +func fitInline(line string, width int) string { + actual := lipgloss.Width(line) + switch { + case actual == width: + return line + case actual < width: + return line + strings.Repeat(" ", width-actual) + default: + return lipgloss.NewStyle().MaxWidth(width).Render(line) + } +} + +// padTo pads a line to a width without truncating it. +func padTo(line string, width int) string { + return padOnto(lipgloss.NewStyle(), line, width) +} + +// padOnto is padTo with the padding drawn in a style, so that a banded row's background runs +// to the edge of the screen rather than stopping where its text does. +func padOnto(style lipgloss.Style, line string, width int) string { + if gap := width - lipgloss.Width(line); gap > 0 { + return line + style.Render(strings.Repeat(" ", gap)) + } + return line +} + +// centre centres text in a width. +func centre(text string, width int) string { + gap := width - lipgloss.Width(text) + if gap <= 0 { + return fitInline(text, width) + } + left := gap / 2 + return strings.Repeat(" ", left) + text + strings.Repeat(" ", gap-left) +} + +// splitLines splits rendered output into lines. +func splitLines(s string) []string { + if s == "" { + return nil + } + return strings.Split(s, "\n") +} + +// renderEventFrames renders the wire bytes captured while a request was in flight. +// +// The association is temporal, not identified: a transport carries no request identifier, so +// what can honestly be shown is the frames that crossed during the request's window. On a +// connection also carrying a subscription that window may include unrelated traffic, and the +// heading says so rather than implying these are the request's own bytes. +func renderEventFrames(theme tui.Theme, event plcsession.Event, log *plcsession.FrameLog) string { + glyphs := theme.Glyphs + + if log == nil { + return theme.Muted.Render("no wire capture") + "\n\n" + + theme.Muted.Render("demo mode simulates the device, so") + "\n" + + theme.Muted.Render("nothing is put on a wire to capture.") + "\n\n" + + theme.Muted.Render("connect to a real device to see bytes here.") + } + + from, to := event.Started, event.Received + if from.IsZero() { + from = to + } + frames := log.Between(from, to) + if len(frames) == 0 { + return theme.Muted.Render("no frames captured for this request") + "\n\n" + + theme.Muted.Render("nothing crossed the transport between") + "\n" + + theme.Muted.Render(from.Format("15:04:05.000")+" and "+to.Format("15:04:05.000")) + "\n\n" + + theme.Muted.Render("press b for the decoded values") + } + + var lines []string + lines = append(lines, + theme.Value.Render("wire frames")+" "+ + theme.Muted.Render("during "+from.Format("15:04:05.000")+strings.Repeat(" ", 1)+glyphs.Separator+" "+to.Format("15:04:05.000"))) + lines = append(lines, theme.Muted.Render("a frame is one transport read or write,")) + lines = append(lines, theme.Muted.Render("not one protocol message")) + lines = append(lines, "") + + for i, frame := range frames { + marker := theme.Ok.Render(glyphs.Outbound) + label := "sent" + if frame.Direction == plcsession.FrameInbound { + marker = theme.Accent.Render(glyphs.Inbound) + label = "received" + } + lines = append(lines, marker+" "+ + theme.Value.Render(label)+" "+ + theme.Muted.Render(frame.At.Format("15:04:05.000")+" "+strconv.Itoa(len(frame.Bytes))+" bytes")) + lines = append(lines, hexDump(theme, frame.Bytes)...) + if i < len(frames)-1 { + lines = append(lines, "") + } + } + return strings.Join(lines, "\n") +} + +// hexBytesPerRow is how many bytes a hex row shows. Eight keeps a row inside a narrow detail +// column, which is where this pane usually lives. +const hexBytesPerRow = 8 + +// hexDump renders bytes as offset, hex and printable text. +func hexDump(theme tui.Theme, data []byte) []string { + var lines []string + for offset := 0; offset < len(data); offset += hexBytesPerRow { + end := min(offset+hexBytesPerRow, len(data)) + chunk := data[offset:end] + + var hex, text strings.Builder + for _, b := range chunk { + fmt.Fprintf(&hex, "%02x ", b) + if b >= 0x20 && b < 0x7f { + text.WriteByte(b) + } else { + text.WriteByte('.') + } + } + // Pad the hex column so the text column lines up on a short final row. + padding := strings.Repeat(" ", hexBytesPerRow-len(chunk)) + lines = append(lines, + theme.Muted.Render(fmt.Sprintf("%08x", offset))+" "+ + theme.Value.Render(hex.String())+padding+ + theme.Muted.Render("|"+text.String()+"|")) + } + return lines +} diff --git a/plc4go/tools/plc4xbrowser/main.go b/plc4go/tools/plc4xbrowser/main.go index 277dea48e..b1ce96b02 100644 --- a/plc4go/tools/plc4xbrowser/main.go +++ b/plc4go/tools/plc4xbrowser/main.go @@ -20,16 +20,19 @@ package main import ( - "github.com/apache/plc4x/plc4go-extras/tools/plc4xbrowser/ui" + "os" + + "github.com/apache/plc4x-extras/plc4go/tools/plc4xbrowser/browser" ) -func main() { - ui.LoadConfig() - application := ui.SetupApplication() - ui.InitSubsystem() +// version labels the status bar. It is a build-time value; the tool deliberately has no +// --version flag. +var version = "1.0.0-SNAPSHOT" - if err := application.Run(); err != nil { - panic(err) +func main() { + // Execute reports its own errors through fang's styled handler, so main only needs to set + // the exit status. + if err := browser.Execute(version); err != nil { + os.Exit(1) } - ui.Shutdown() } diff --git a/plc4go/tools/plc4xbrowser/ui/actions.go b/plc4go/tools/plc4xbrowser/ui/actions.go deleted file mode 100644 index a4b25ae9c..000000000 --- a/plc4go/tools/plc4xbrowser/ui/actions.go +++ /dev/null @@ -1,152 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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. - */ - -package ui - -import ( - "bytes" - "fmt" - "github.com/apache/plc4x/plc4go/pkg/api/drivers" - "strings" - - plc4go "github.com/apache/plc4x/plc4go/pkg/api" - "github.com/apache/plc4x/plc4go/pkg/api/transports" - - "github.com/pkg/errors" - "github.com/rivo/tview" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" -) - -func InitSubsystem() { - logLevel := zerolog.InfoLevel - if configuredLevel := config.LogLevel; configuredLevel != "" { - if parsedLevel, err := zerolog.ParseLevel(configuredLevel); err != nil { - panic(err) - } else { - logLevel = parsedLevel - } - } - - log.Logger = log. - //// Enable below if you want to see the filenames - //With().Caller().Logger(). - Output(zerolog.NewConsoleWriter( - func(w *zerolog.ConsoleWriter) { - w.Out = tview.ANSIWriter(consoleOutput) - }, - func(w *zerolog.ConsoleWriter) { - w.FormatFieldValue = func(i interface{}) string { - if aString, ok := i.(string); ok && strings.Contains(aString, "\\n") { - return fmt.Sprintf("\x1b[%dm%v\x1b[0m", 31, "see below") - } - return fmt.Sprintf("%s", i) - } - w.FormatExtra = func(m map[string]interface{}, buffer *bytes.Buffer) error { - for key, i := range m { - if aString, ok := i.(string); ok && strings.Contains(aString, "\n") { - buffer.WriteString("\n") - buffer.WriteString(fmt.Sprintf("\x1b[%dm%v\x1b[0m", 32, "field "+key)) - buffer.WriteString(":\n" + aString) - } - } - return nil - } - }, - ), - ). - Level(logLevel) - - driverManager = plc4go.NewPlcDriverManager() - - // We offset the commands executed with the last commands - commandsExecuted = len(config.History.Last10Commands) - outputCommandHistory() - - for _, driver := range config.AutoRegisterDrivers { - log.Info().Str("driver", driver).Msg("Auto register driver") - if err := validateDriverParam(driver); err != nil { - log.Err(err).Msg("Invalid configuration") - continue - } - _ = registerDriver(driver) - } -} - -func outputCommandHistory() { - _, _ = fmt.Fprintln(commandOutput, "[#0000ff]Last 10 commands[white]") - for i, command := range config.History.Last10Commands { - _, _ = fmt.Fprintf(commandOutput, " [#00ff00]%d[white]: [\"%d\"]%s[\"\"]\n", i, i, tview.Escape(command)) - } -} - -func validateDriverParam(driver string) error { - for _, protocol := range protocolList { - if protocol == driver { - return nil - } - } - return errors.Errorf("protocol %s not found", driver) -} - -var tcpRegistered, udpRegistered bool - -func registerDriver(driverId string) error { - if _, ok := registeredDrivers[driverId]; ok { - return errors.Errorf("%s already registered", driverId) - } - var driver plc4go.PlcDriver - switch driverId { - case "ads": - driver = drivers.RegisterAdsDriver(driverManager) - if !tcpRegistered { - transports.RegisterTcpTransport(driverManager) - tcpRegistered = true - } - case "bacnetip": - driver = drivers.RegisterBacnetDriver(driverManager) - if !udpRegistered { - transports.RegisterUdpTransport(driverManager) - udpRegistered = true - } - case "c-bus": - driver = drivers.RegisterCBusDriver(driverManager) - if !tcpRegistered { - transports.RegisterTcpTransport(driverManager) - tcpRegistered = true - } - case "s7": - driver = drivers.RegisterS7Driver(driverManager) - if !tcpRegistered { - transports.RegisterTcpTransport(driverManager) - tcpRegistered = true - } - case "opcua": - driver = drivers.RegisterOpcuaDriver(driverManager) - if !tcpRegistered { - transports.RegisterTcpTransport(driverManager) - tcpRegistered = true - } - default: - return errors.Errorf("Unknown driver %s", driverId) - } - registeredDrivers[driverId] = driver - go driverAdded(driver) - return nil -} diff --git a/plc4go/tools/plc4xbrowser/ui/commands.go b/plc4go/tools/plc4xbrowser/ui/commands.go deleted file mode 100644 index 0d13f3019..000000000 --- a/plc4go/tools/plc4xbrowser/ui/commands.go +++ /dev/null @@ -1,803 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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. - */ - -package ui - -import ( - "fmt" - "net/url" - "runtime/debug" - "strings" - "time" - - plc4xConfig "github.com/apache/plc4x/plc4go/pkg/api/config" - apiModel "github.com/apache/plc4x/plc4go/pkg/api/model" - - "github.com/pkg/errors" - "github.com/rivo/tview" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" -) - -const rootCommandIndicator = "rootCommand" - -var commands = map[inputMode]Command{ - normalMode: rootCommand, - readEditMode: rootCommand, - writeEditMode: rootCommand, - subscribeEditMode: rootCommand, -} - -var rootCommand = Command{ - Name: rootCommandIndicator, - subCommands: []Command{ - { - Name: "discover", - Description: "Discovers devices", - action: func(_ Command, driverId string) error { - if driver, ok := registeredDrivers[driverId]; ok { - if !driver.SupportsDiscovery() { - return errors.Errorf("%s doesn't support discovery", driverId) - } - return driver.Discover(func(event apiModel.PlcDiscoveryItem) { - _, _ = fmt.Fprintf(messageOutput, "%v\n", event) - }) - } else { - return errors.Errorf("%s not registered", driverId) - } - }, - parameterSuggestions: func(currentText string) (entries []string) { - for _, protocol := range protocolList { - entries = append(entries, protocol) - } - return - }, - }, - { - Name: "connect", - Description: "Connects to a device", - action: func(_ Command, connectionString string) error { - log.Info().Str("connectionString", connectionString).Msg("connect connectionString") - connectionUrl, err := url.Parse(connectionString) - if err != nil { - return errors.Wrapf(err, "can't parse connection url %s", connectionString) - } - addHostHistoryEntry(connectionUrl.Host) - connectionId := fmt.Sprintf("%s://%s", connectionUrl.Scheme, connectionUrl.Host) - if _, ok := connections[connectionId]; ok { - return errors.Errorf("%s already connected", connectionId) - } - connectionResult := <-driverManager.GetConnection(connectionString) - if err := connectionResult.GetErr(); err != nil { - return errors.Wrapf(err, "%s can't connect to", connectionUrl.Host) - } - log.Info().Str("connectionId", connectionId).Msg("connected") - connections[connectionId] = connectionResult.GetConnection() - connectionsChanged() - return nil - }, - parameterSuggestions: func(currentText string) (entries []string) { - for _, protocol := range protocolList { - if strings.HasPrefix(currentText, protocol) { - for _, host := range config.History.Last10Hosts { - entries = append(entries, protocol+"://"+host) - } - entries = append(entries, currentText) - } else { - entries = append(entries, protocol) - } - } - return - }, - }, - { - Name: "disconnect", - Description: "Disconnect a connection", - action: func(_ Command, connectionString string) error { - if connection, ok := connections[connectionString]; !ok { - return errors.Errorf("%s not connected", connectionString) - } else { - closeResult := <-connection.Close() - log.Info().Str("connectionString", connectionString).Msg("connectionString disconnected") - delete(connections, connectionString) - connectionsChanged() - if err := closeResult.GetErr(); err != nil { - return errors.Wrapf(err, "%s can't close", connectionString) - } - } - return nil - }, - parameterSuggestions: func(currentText string) (entries []string) { - for connectionsString := range connections { - entries = append(entries, connectionsString) - } - return - }, - }, - { - Name: "read", - Description: "Starts a read request (switched mode to read edit)", - action: func(_ Command, connectionsString string) error { - if connection, ok := connections[connectionsString]; !ok { - return errors.Errorf("%s not connected", connectionsString) - } else { - return errors.Errorf("%s mode switch not yet implemented", connection) - } - }, - parameterSuggestions: func(currentText string) (entries []string) { - for connectionsString := range connections { - entries = append(entries, connectionsString) - } - return - }, - }, - { - Name: "read-direct", - Description: "Builds a read request with the supplied field", - action: func(c Command, connectionsStringAndFieldQuery string) error { - split := strings.Split(connectionsStringAndFieldQuery, " ") - if len(split) != 2 { - return errors.Errorf("%s expects exactly two arguments [connection url] [fieldQuery]", c) - } - connectionsString := split[0] - if connection, ok := connections[connectionsString]; !ok { - return errors.Errorf("%s not connected", connectionsString) - } else { - start := time.Now() - readRequest, err := connection.ReadRequestBuilder(). - AddTagAddress("readField", split[1]). - Build() - if err != nil { - return errors.Wrapf(err, "%s can't read", connectionsString) - } - readRequestResult := <-readRequest.Execute() - if err := readRequestResult.GetErr(); err != nil { - return errors.Wrapf(err, "%s can't read", connectionsString) - } - plc4xBrowserLog.Debug().TimeDiff("runtime", time.Now(), start).Msg("read took runtime") - if err := readRequestResult.GetErr(); err != nil { - return errors.Wrapf(err, "%s error reading", connectionsString) - } - numberOfMessagesReceived++ - messageReceived(numberOfMessagesReceived, time.Now(), readRequestResult.GetResponse()) - } - return nil - }, - parameterSuggestions: func(currentText string) (entries []string) { - for connectionsString := range connections { - if strings.HasPrefix(currentText, connectionsString+"") { - parse, _ := url.Parse(connectionsString) - switch parse.Scheme { - // TODO: add to protocol suggestor so it can be reused. - - } - } else { - entries = append(entries, connectionsString) - } - } - return - }, - }, - { - Name: "write", - Description: "Starts a write request (switched mode to write edit)", - action: func(_ Command, connectionsString string) error { - if connection, ok := connections[connectionsString]; !ok { - return errors.Errorf("%s not connected", connectionsString) - } else { - return errors.Errorf("%s mode switch not yet implemented", connection) - } - }, - parameterSuggestions: func(currentText string) (entries []string) { - for connectionsString := range connections { - entries = append(entries, connectionsString) - } - return - }, - }, - { - Name: "write-direct", - Description: "Builds a write request with the supplied field", - action: func(c Command, connectionsStringAndFieldQuery string) error { - split := strings.Split(connectionsStringAndFieldQuery, " ") - if len(split) != 3 { - return errors.Errorf("%s expects exactly three arguments [connection url] [fieldQuery] [value]", c) - } - connectionsString := split[0] - if connection, ok := connections[connectionsString]; !ok { - return errors.Errorf("%s not connected", connectionsString) - } else { - start := time.Now() - writeRequest, err := connection.WriteRequestBuilder(). - AddTagAddress("writeField", split[1], split[2]). - Build() - if err != nil { - return errors.Wrapf(err, "%s can't write", connectionsString) - } - writeRequestResult := <-writeRequest.Execute() - if err := writeRequestResult.GetErr(); err != nil { - return errors.Wrapf(err, "%s can't write", connectionsString) - } - plc4xBrowserLog.Debug().TimeDiff("runtime", time.Now(), start).Msg("write took runtime") - if err := writeRequestResult.GetErr(); err != nil { - return errors.Wrapf(err, "%s error writing", connectionsString) - } - numberOfMessagesReceived++ - messageReceived(numberOfMessagesReceived, time.Now(), writeRequestResult.GetResponse()) - } - return nil - }, - parameterSuggestions: func(currentText string) (entries []string) { - for connectionsString := range connections { - if strings.HasPrefix(currentText, connectionsString+"") { - parse, _ := url.Parse(connectionsString) - switch parse.Scheme { - // TODO: add to protocol suggestor so it can be reused. - - } - } else { - entries = append(entries, connectionsString) - } - } - return - }, - }, - { - Name: "browse", - Description: "Starts a browse request (switched mode to browse edit)", - action: func(_ Command, connectionsString string) error { - if connection, ok := connections[connectionsString]; !ok { - return errors.Errorf("%s not connected", connectionsString) - } else { - return errors.Errorf("%s mode switch not yet implemented", connection) - } - }, - parameterSuggestions: func(currentText string) (entries []string) { - for connectionsString := range connections { - entries = append(entries, connectionsString) - } - return - }, - }, - { - Name: "browse-direct", - Description: "Builds a browse request with the supplied field", - action: func(c Command, connectionsStringAndFieldQuery string) error { - split := strings.Split(connectionsStringAndFieldQuery, " ") - if len(split) != 2 { - return errors.Errorf("%s expects exactly three arguments [connection url] [fieldQuery]", c) - } - connectionsString := split[0] - if connection, ok := connections[connectionsString]; !ok { - return errors.Errorf("%s not connected", connectionsString) - } else { - start := time.Now() - browseRequest, err := connection.BrowseRequestBuilder(). - AddQuery("browseField", split[1]). - Build() - if err != nil { - return errors.Wrapf(err, "%s can't browse", connectionsString) - } - browseRequestResult := <-browseRequest.ExecuteWithInterceptor(func(result apiModel.PlcBrowseItem) bool { - // TODO: Disabled for now ... not quite sure what this is for ... - //numberOfMessagesReceived++ - //messageReceived(numberOfMessagesReceived, time.Now(), result) - return true - }) - if err := browseRequestResult.GetErr(); err != nil { - return errors.Wrapf(err, "%s can't browse", connectionsString) - } - plc4xBrowserLog.Debug().TimeDiff("runtime", time.Now(), start).Msg("write took runtime") - if err := browseRequestResult.GetErr(); err != nil { - return errors.Wrapf(err, "%s error browse", connectionsString) - } - numberOfMessagesReceived++ - messageReceived(numberOfMessagesReceived, time.Now(), browseRequestResult.GetResponse()) - } - return nil - }, - parameterSuggestions: func(currentText string) (entries []string) { - for connectionsString := range connections { - if strings.HasPrefix(currentText, connectionsString+"") { - parse, _ := url.Parse(connectionsString) - switch parse.Scheme { - // TODO: add to protocol suggestor so it can be reused. - case "c-bus": - entries = append(entries, connectionsString+" info/*/*") - } - } else { - entries = append(entries, connectionsString) - } - } - return - }, - }, - { - Name: "register", - Description: "register a driver in the subsystem", - action: func(_ Command, driver string) error { - return registerDriver(driver) - }, - parameterSuggestions: func(currentText string) (entries []string) { - for _, protocol := range protocolList { - if strings.HasPrefix(protocol, currentText) { - entries = append(entries, protocol) - } - } - return - }, - }, - { - Name: "subscribe", - Description: "Starts a subscription request (switched mode to subscribe edit)", - action: func(_ Command, connectionsString string) error { - if connection, ok := connections[connectionsString]; !ok { - return errors.Errorf("%s not connected", connectionsString) - } else { - return errors.Errorf("%s mode switch not yet implemented", connection) - } - }, - parameterSuggestions: func(currentText string) (entries []string) { - for connectionsString := range connections { - entries = append(entries, connectionsString) - } - return - }, - }, - { - Name: "subscribe-direct", - Description: "Builds a subscriptions request with the supplied field", - action: func(c Command, connectionsStringAndFieldQuery string) error { - split := strings.Split(connectionsStringAndFieldQuery, " ") - if len(split) != 2 { - return errors.Errorf("%s expects exactly two arguments [connection url] [fieldQuery]", c) - } - connectionsString := split[0] - if connection, ok := connections[connectionsString]; !ok { - return errors.Errorf("%s not connected", connectionsString) - } else { - subscriptionRequest, err := connection.SubscriptionRequestBuilder(). - AddEventTagAddress("subscriptionField", split[1]). - AddPreRegisteredConsumer("subscriptionField", func(event apiModel.PlcSubscriptionEvent) { - numberOfMessagesReceived++ - messageReceived(numberOfMessagesReceived, time.Now(), event) - }). - Build() - if err != nil { - return errors.Wrapf(err, "%s can't subscribe", connectionsString) - } - subscriptionRequestResult := <-subscriptionRequest.Execute() - if err := subscriptionRequestResult.GetErr(); err != nil { - return errors.Wrapf(err, "%s can't subscribe", connectionsString) - } - log.Info().Stringer("response", subscriptionRequestResult.GetResponse()).Msg("subscription result") - } - return nil - }, - parameterSuggestions: func(currentText string) (entries []string) { - for connectionsString := range connections { - entries = append(entries, connectionsString) - if strings.HasPrefix(currentText, connectionsString) { - parse, _ := url.Parse(connectionsString) - switch parse.Scheme { - // TODO: add to protocol suggestor so it can be reused. - case "c-bus": - entries = append(entries, connectionsString+" salmonitor/*/*") - entries = append(entries, connectionsString+" mmimonitor/*/*") - } - } - } - return - }, - }, - { - Name: "quit", - Description: "Quits the application", - }, - { - Name: "log", - Description: "Log related operations", - subCommands: []Command{ - { - Name: "get", - Description: "Get a log level", - action: func(_ Command, _ string) error { - _, _ = fmt.Fprintf(commandOutput, "Current log level %s", log.Logger.GetLevel()) - return nil - }, - }, - { - Name: "set", - Description: "Sets a log level", - action: func(_ Command, level string) error { - parseLevel, err := zerolog.ParseLevel(level) - if err != nil { - return errors.Wrapf(err, "Error setting log level") - } - setLevel(parseLevel) - log.Logger = log.Logger.Level(parseLevel) - return nil - }, - parameterSuggestions: func(currentText string) (entries []string) { - levels := []string{ - zerolog.LevelTraceValue, - zerolog.LevelDebugValue, - zerolog.LevelInfoValue, - zerolog.LevelWarnValue, - zerolog.LevelErrorValue, - zerolog.LevelFatalValue, - zerolog.LevelPanicValue, - } - for _, level := range levels { - entries = append(entries, level) - } - return - }, - }, - }, - }, - { - Name: "plc4x-conf", - Description: "plc4x related settings", - subCommands: []Command{ - { - Name: "TraceTransactionManagerWorkers", - Description: "print information about transaction manager workers", - subCommands: []Command{ - { - Name: "on", - Description: "trace on", - action: func(_ Command, _ string) error { - plc4xConfig.TraceTransactionManagerWorkers = true - return nil - }, - }, - { - Name: "off", - Description: "trace off", - action: func(_ Command, _ string) error { - plc4xConfig.TraceTransactionManagerWorkers = false - return nil - }, - }, - }, - }, - { - Name: "TraceTransactionManagerTransactions", - Description: "print information about transaction manager transactions", - subCommands: []Command{ - { - Name: "on", - Description: "trace on", - action: func(_ Command, _ string) error { - plc4xConfig.TraceTransactionManagerTransactions = true - return nil - }, - }, - { - Name: "off", - Description: "trace off", - action: func(_ Command, _ string) error { - plc4xConfig.TraceTransactionManagerTransactions = false - return nil - }, - }, - }, - }, - { - Name: "TraceDefaultMessageCodecWorker", - Description: "print information about message codec workers", - subCommands: []Command{ - { - Name: "on", - Description: "trace on", - action: func(_ Command, _ string) error { - plc4xConfig.TraceDefaultMessageCodecWorker = true - return nil - }, - }, - { - Name: "off", - Description: "trace off", - action: func(_ Command, _ string) error { - plc4xConfig.TraceDefaultMessageCodecWorker = false - return nil - }, - }, - }, - }, - { - Name: "plc4xbrowser-debug", - Description: "Prints out debug information of the browser itself", - subCommands: []Command{ - { - Name: "on", - Description: "debug on", - action: func(_ Command, _ string) error { - plc4xBrowserLog = zerolog.New(zerolog.ConsoleWriter{Out: tview.ANSIWriter(consoleOutput)}) - return nil - }, - }, - { - Name: "off", - Description: "debug off", - action: func(_ Command, _ string) error { - plc4xBrowserLog = zerolog.Nop() - return nil - }, - }, - }, - }, - { - Name: "auto-register", - Description: "autoregister driver at startup", - subCommands: []Command{ - { - Name: "list", - action: func(currentCommand Command, argument string) error { - _, _ = fmt.Fprintf(commandOutput, "Auto-register enabled drivers:\n %s\n", strings.Join(config.AutoRegisterDrivers, "\n ")) - return nil - }, - }, - { - Name: "enable", - action: func(_ Command, argument string) error { - return enableAutoRegister(argument) - }, - parameterSuggestions: func(currentText string) (entries []string) { - for _, protocol := range protocolList { - if strings.HasPrefix(protocol, currentText) { - entries = append(entries, protocol) - } - } - return - }, - }, - { - Name: "disable", - action: func(_ Command, argument string) error { - return disableAutoRegister(argument) - }, - parameterSuggestions: func(currentText string) (entries []string) { - for _, protocol := range protocolList { - if strings.HasPrefix(protocol, currentText) { - entries = append(entries, protocol) - } - } - return - }, - }, - }, - }, - }, - }, - { - Name: "history", - Description: "outputs the last commands", - action: func(_ Command, _ string) error { - outputCommandHistory() - return nil - }, - }, - { - Name: "clear", - Description: "clear all outputs", - action: func(_ Command, _ string) error { - messageOutputClear() - consoleOutputClear() - commandOutputClear() - return nil - }, - subCommands: []Command{ - { - Name: "message", - Description: "clears message output", - action: func(_ Command, _ string) error { - messageOutputClear() - return nil - }, - }, - { - Name: "console", - Description: "clears console output", - action: func(_ Command, _ string) error { - consoleOutputClear() - return nil - }, - }, - { - Name: "command", - Description: "clears command output", - action: func(_ Command, _ string) error { - commandOutputClear() - return nil - }, - }, - }, - }, - }, -} - -func init() { - // Because of the cycle we need to define the help command here as it needs access to the to command - rootCommand.subCommands = append(rootCommand.subCommands, Command{ - Name: "help", - Description: "prints out this help", - action: func(_ Command, _ string) error { - _, _ = fmt.Fprintf(commandOutput, "[#0000ff]Available commands[white]\n") - rootCommand.visit(0, func(currentIndent int, command Command) { - indentString := strings.Repeat(" ", currentIndent) - description := command.Description - if description == "" { - description = command.Name + "s" - } - _, _ = fmt.Fprintf(commandOutput, "%s [#00ff00]%s[white]: %s\n", indentString, command.Name, description) - }) - return nil - }, - }) -} - -var NotDirectlyExecutable = errors.New("Not directly executable") - -type Command struct { - Name string - Description string - action func(currentCommand Command, argument string) error - subCommands []Command - parameterSuggestions func(currentText string) (entries []string) -} - -func (c Command) Completions(currentCommandText string) (entries []string) { - if c.Name == rootCommandIndicator && len(currentCommandText) == 0 { - // We don't return anything here to not pollute the command text by default - return - } - if c.acceptsCurrentText(currentCommandText) { - currentCommandPrefix := c.currentCommandPrefix() - doesCommandTextTargetSubCommand := c.doesCommandTextTargetSubCommand(currentCommandPrefix) - if c.hasDirectExecution() && !doesCommandTextTargetSubCommand { - if c.parameterSuggestions != nil { - preparedForParameters := c.prepareForParameters(currentCommandText) - for _, parameterSuggestion := range c.parameterSuggestions(preparedForParameters) { - entries = append(entries, currentCommandPrefix+parameterSuggestion) - } - } else { - entries = append(entries, c.Name) - } - } - if doesCommandTextTargetSubCommand { - remainder := c.prepareForSubCommand(currentCommandText) - for _, command := range c.subCommands { - for _, subCommandCompletions := range command.Completions(remainder) { - entries = append(entries, currentCommandPrefix+subCommandCompletions) - } - } - } - } else if strings.HasPrefix(c.Name, currentCommandText) { - // Suggest ourselves if we start with the current letter - entries = append(entries, c.Name) - } - return -} - -func (c Command) acceptsCurrentText(currentCommandText string) bool { - if c.Name == rootCommandIndicator { - return true - } - hasThePrefix := strings.HasPrefix(currentCommandText, c.Name) - hasNoMatchingAlternative := !strings.HasPrefix(currentCommandText, c.Name+"-") - accepts := hasThePrefix && hasNoMatchingAlternative - plc4xBrowserLog.Debug(). - Stringer("c", c). - Bool("accepts", accepts). - Msg("c accepts") - return accepts -} - -func (c Command) doesCommandTextTargetSubCommand(currentCommandText string) bool { - if c.Name == rootCommandIndicator { - return true - } - if len(c.subCommands) == 0 { - return false - } - return strings.HasPrefix(currentCommandText, c.currentCommandPrefix()) -} - -func (c Command) prepareForParameters(currentCommandText string) string { - return strings.TrimPrefix(currentCommandText, c.currentCommandPrefix()) -} -func (c Command) prepareForSubCommand(currentCommandText string) string { - return strings.TrimPrefix(currentCommandText, c.currentCommandPrefix()) -} - -func (c Command) currentCommandPrefix() string { - if c.Name == rootCommandIndicator { - return "" - } - return c.Name + " " -} - -func (c Command) hasDirectExecution() bool { - return c.action != nil -} - -func Execute(commandText string) error { - err := rootCommand.Execute(commandText) - if err == nil { - addCommandHistoryEntry(commandText) - } - return err -} - -func (c Command) Execute(commandText string) (err error) { - defer func() { - if recoveredErr := recover(); recoveredErr != nil { - if log.Debug().Enabled() { - log.Error(). - Str("stack", string(debug.Stack())). - Interface("err", err). - Msg("panic-ed") - } - err = errors.Errorf("panic occurred: %v.", recoveredErr) - } - }() - plc4xBrowserLog.Debug(). - Stringer("c", c). - Str("commandText", commandText). - Msg("%s executes %s") - if !c.acceptsCurrentText(commandText) { - return errors.Errorf("%s doesn't understand %s", c.Name, commandText) - } - if c.doesCommandTextTargetSubCommand(commandText) { - prepareForSubCommandForSubCommand := c.prepareForSubCommand(commandText) - for _, command := range c.subCommands { - if command.acceptsCurrentText(prepareForSubCommandForSubCommand) { - plc4xBrowserLog.Debug(). - Stringer("c", c). - Stringer("command", command). - Msg("c delegates to sub command") - return command.Execute(prepareForSubCommandForSubCommand) - } - } - return errors.Errorf("%s not accepted by any subcommands of %s", commandText, c.Name) - } else { - if c.action == nil { - return NotDirectlyExecutable - } - plc4xBrowserLog.Debug(). - Stringer("c", c). - Str("commandText", commandText). - Msg("c executes commandText directly") - preparedForParameters := c.prepareForParameters(commandText) - return c.action(c, preparedForParameters) - } -} - -func (c Command) visit(i int, f func(currentIndent int, command Command)) { - f(i, c) - for _, subCommand := range c.subCommands { - subCommand.visit(i+1, f) - } -} - -func (c Command) String() string { - return c.Name -} diff --git a/plc4go/tools/plc4xbrowser/ui/common.go b/plc4go/tools/plc4xbrowser/ui/common.go deleted file mode 100644 index 7eaa32354..000000000 --- a/plc4go/tools/plc4xbrowser/ui/common.go +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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. - */ - -package ui - -import ( - "io" - "strings" - "sync" - "time" - - plc4go "github.com/apache/plc4x/plc4go/pkg/api" - plc4goModel "github.com/apache/plc4x/plc4go/pkg/api/model" - - "github.com/rs/zerolog" -) - -const protocols = "ads,bacnetip,c-bus,opcua,s7" - -var protocolList = strings.Split(protocols, ",") - -var plc4xBrowserLog = zerolog.Nop() - -var driverManager plc4go.PlcDriverManager -var registeredDrivers map[string]plc4go.PlcDriver -var driverAdded func(driver plc4go.PlcDriver) -var connections map[string]plc4go.PlcConnection -var connectionsChanged func() - -var messageReceived func(messageNumber int, receiveTime time.Time, message plc4goModel.PlcMessage) -var numberOfMessagesReceived int -var messageOutput io.Writer -var messageOutputClear func() - -var consoleOutput io.Writer -var consoleOutputClear func() - -var commandsExecuted int -var commandOutput io.Writer -var commandOutputClear func() - -type inputMode int - -const ( - normalMode inputMode = iota - readEditMode - writeEditMode - subscribeEditMode -) - -func init() { - hasShutdown = false - registeredDrivers = make(map[string]plc4go.PlcDriver) - connections = make(map[string]plc4go.PlcConnection) -} - -var shutdownMutex sync.Mutex -var hasShutdown bool - -func Shutdown() { - shutdownMutex.Lock() - defer shutdownMutex.Unlock() - if hasShutdown { - return - } - for _, connection := range connections { - connection.Close() - } - hasShutdown = true - saveConfig() -} diff --git a/plc4go/tools/plc4xbrowser/ui/config.go b/plc4go/tools/plc4xbrowser/ui/config.go deleted file mode 100644 index 02c6a51db..000000000 --- a/plc4go/tools/plc4xbrowser/ui/config.go +++ /dev/null @@ -1,187 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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. - */ - -package ui - -import ( - "os" - "path" - "sync" - "time" - - "github.com/pkg/errors" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" - "gopkg.in/yaml.v3" -) - -var plc4xBrowserConfigDir string -var configFile string -var config = Config{ - MaxConsoleLines: 500, - MaxOutputLines: 500, -} - -type Config struct { - History struct { - Last10Hosts []string `yaml:"last_hosts"` - Last10Commands []string `yaml:"last_commands"` - } - AutoRegisterDrivers []string `yaml:"auto_register_driver"` - LastUpdated time.Time `yaml:"last_updated"` - LogLevel string `yaml:"log_level"` - MaxConsoleLines int `yaml:"max_console_lines"` - MaxOutputLines int `yaml:"max_output_lines"` -} - -func init() { - userConfigDir, err := os.UserConfigDir() - if err != nil { - panic(err) - } - plc4xBrowserConfigDir = path.Join(userConfigDir, "plc4xbrowser") - if _, err := os.Stat(plc4xBrowserConfigDir); os.IsNotExist(err) { - err := os.Mkdir(plc4xBrowserConfigDir, os.ModeDir|os.ModePerm) - if err != nil { - panic(err) - } - } - configFile = path.Join(plc4xBrowserConfigDir, "config.yml") -} - -func LoadConfig() { - f, err := os.Open(configFile) - if err != nil { - log.Info().Err(err).Msg("No config file found") - return - } - defer func(f *os.File) { - err := f.Close() - if err != nil { - log.Error().Err(err).Msg("Error closing config file") - } - }(f) - - decoder := yaml.NewDecoder(f) - if err = decoder.Decode(&config); err != nil { - log.Warn().Err(err).Msg("Can't decode config file") - return - } -} - -var saveMutex sync.Mutex - -func saveConfig() { - saveMutex.Lock() - defer saveMutex.Unlock() - config.LastUpdated = time.Now() - f, err := os.OpenFile(configFile, os.O_RDWR|os.O_CREATE, 0755) - if err != nil { - log.Warn().Err(err).Msg("Can't save config file") - return - } - encoder := yaml.NewEncoder(f) - defer func(encoder *yaml.Encoder) { - err := encoder.Close() - if err != nil { - log.Error().Err(err).Msg("Error closing config file") - } - }(encoder) - if err := encoder.Encode(config); err != nil { - log.Warn().Err(err).Msg("Can't encode config file") - panic(err) - } -} - -func addHostHistoryEntry(host string) { - existingIndex := -1 - for i, lastHost := range config.History.Last10Hosts { - if lastHost == host { - existingIndex = i - break - } - } - if existingIndex >= 0 { - config.History.Last10Hosts = append(config.History.Last10Hosts[:existingIndex], config.History.Last10Hosts[existingIndex+1:]...) - } - if len(config.History.Last10Hosts) >= 10 { - config.History.Last10Hosts = config.History.Last10Hosts[1:] - } - config.History.Last10Hosts = append(config.History.Last10Hosts, host) -} - -func addCommandHistoryEntry(command string) { - switch command { - case "clear": - return - case "history": - return - } - existingIndex := -1 - for i, lastCommand := range config.History.Last10Commands { - if lastCommand == command { - existingIndex = i - break - } - } - if existingIndex >= 0 { - config.History.Last10Commands = append(config.History.Last10Commands[:existingIndex], config.History.Last10Commands[existingIndex+1:]...) - } - if len(config.History.Last10Commands) >= 10 { - config.History.Last10Commands = config.History.Last10Commands[1:] - } - config.History.Last10Commands = append(config.History.Last10Commands, command) -} - -func setLevel(level zerolog.Level) { - config.LogLevel = level.String() -} - -func enableAutoRegister(driver string) error { - if err := validateDriverParam(driver); err != nil { - return err - } - for _, autoRegisterDriver := range config.AutoRegisterDrivers { - if autoRegisterDriver == driver { - return errors.Errorf("%s already registered for auto register", driver) - } - } - config.AutoRegisterDrivers = append(config.AutoRegisterDrivers, driver) - log.Info().Str("driver", driver).Msg("Auto register enabled") - return nil -} - -func disableAutoRegister(driver string) error { - if err := validateDriverParam(driver); err != nil { - return err - } - index := -1 - for i, autoRegisterDriver := range config.AutoRegisterDrivers { - if autoRegisterDriver == driver { - index = i - break - } - } - if index < 0 { - return errors.Errorf("%s not registered for auto register", driver) - } - config.AutoRegisterDrivers = append(config.AutoRegisterDrivers[:index], config.AutoRegisterDrivers[index+1:]...) - log.Info().Str("driver", driver).Msg("Auto register disabled") - return nil -} diff --git a/plc4go/tools/plc4xbrowser/ui/ui.go b/plc4go/tools/plc4xbrowser/ui/ui.go deleted file mode 100644 index 92de8f483..000000000 --- a/plc4go/tools/plc4xbrowser/ui/ui.go +++ /dev/null @@ -1,300 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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. - */ - -package ui - -import ( - "fmt" - "regexp" - "strconv" - "time" - - plc4go "github.com/apache/plc4x/plc4go/pkg/api" - apiModel "github.com/apache/plc4x/plc4go/pkg/api/model" - - "github.com/gdamore/tcell/v2" - "github.com/pkg/errors" - "github.com/rivo/tview" -) - -func SetupApplication() *tview.Application { - application := tview.NewApplication() - - newPrimitive := func(text string) tview.Primitive { - return tview.NewTextView(). - SetTextAlign(tview.AlignCenter). - SetText(text) - } - connectionArea := buildConnectionArea(newPrimitive, application) - outputArea := buildOutputArea(newPrimitive, application) - commandArea := buildCommandArea(newPrimitive, application) - - grid := tview.NewGrid(). - SetRows(1, 0, 1). - SetColumns(30, 0, 30). - SetBorders(true). - AddItem(newPrimitive("PLC4X Browser"), 0, 0, 1, 3, 0, 0, false). - AddItem(newPrimitive("https://github.com/apache/plc4x"), 2, 0, 1, 3, 0, 0, false) - - // Layout for screens narrower than 100 cells (connectionArea and side bar are hidden). - grid.AddItem(connectionArea, 0, 0, 0, 0, 0, 0, false). - AddItem(outputArea, 1, 0, 1, 3, 0, 0, false). - AddItem(commandArea, 0, 0, 0, 0, 0, 0, true) - - // Layout for screens wider than 100 cells. - grid.AddItem(connectionArea, 1, 0, 1, 1, 0, 100, false). - AddItem(outputArea, 1, 1, 1, 1, 0, 100, false). - AddItem(commandArea, 1, 2, 1, 1, 0, 100, false) - - application.SetRoot(grid, true).EnableMouse(true) - - return application -} - -func buildConnectionArea(newPrimitive func(text string) tview.Primitive, application *tview.Application) tview.Primitive { - connectionAreaHeader := newPrimitive("Connections") - connectionArea := tview.NewGrid(). - SetRows(3, 0, 10). - SetColumns(0). - AddItem(connectionAreaHeader, 0, 0, 1, 1, 0, 0, false) - { - connectionList := tview.NewList() - connectionsChanged = func() { - application.QueueUpdateDraw(func() { - connectionList.Clear() - for connectionString, connection := range connections { - connectionList.AddItem(connectionString, "", 0x0, func() { - //TODO: disconnect popup - _ = connection - }) - } - }) - } - connectionArea.AddItem(connectionList, 1, 0, 1, 1, 0, 0, false) - { - registeredDriverAreaHeader := newPrimitive("Registered drivers") - registeredDriverArea := tview.NewGrid(). - SetRows(3, 0). - SetColumns(0). - AddItem(registeredDriverAreaHeader, 0, 0, 1, 1, 0, 0, false) - { - driverList := tview.NewList() - driverAdded = func(driver plc4go.PlcDriver) { - application.QueueUpdateDraw(func() { - driverList.AddItem(driver.GetProtocolCode(), tview.Escape(fmt.Sprintf("%s", driver)), 0x0, func() { - //TODO: disconnect popup - }) - }) - } - registeredDriverArea.AddItem(driverList, 1, 0, 1, 1, 0, 0, false) - } - connectionArea.AddItem(registeredDriverArea, 2, 0, 1, 1, 0, 0, false) - } - - } - return connectionArea -} - -func buildCommandArea(newPrimitive func(text string) tview.Primitive, application *tview.Application) tview.Primitive { - commandAreaHeader := newPrimitive("Commands") - commandArea := tview.NewGrid(). - SetRows(3, 0, 3). - SetColumns(0). - AddItem(commandAreaHeader, 0, 0, 1, 1, 0, 0, false) - { - enteredCommandsView := tview.NewTextView(). - SetDynamicColors(true). - SetRegions(true). - SetWordWrap(true). - SetChangedFunc(func() { - application.Draw() - }) - commandOutput = enteredCommandsView - commandOutputClear = func() { - enteredCommandsView.SetText("") - } - - commandArea.AddItem(enteredCommandsView, 1, 0, 1, 1, 0, 0, false) - - commandInputField := tview.NewInputField(). - SetLabel("$"). - SetFieldWidth(30) - application.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - switch event.Key() { - case tcell.KeyCtrlC: - commandInputField.SetText("") - application.SetFocus(commandInputField) - return nil - case tcell.KeyCtrlD: - // TODO: maybe add a modal here - application.Stop() - return nil - } - return event - }) - commandInputField. - SetDoneFunc(func(key tcell.Key) { - commandText := commandInputField.GetText() - if commandText == "quit" { - // TODO: maybe add a modal here - application.Stop() - return - } - commandsExecuted++ - go func() { - commandHistoryShortcut, _ := regexp.Compile("^[0-9]$") - if commandHistoryShortcut.MatchString(commandText) { - atoi, _ := strconv.Atoi(commandHistoryShortcut.FindString(commandText)) - if atoi < len(config.History.Last10Commands) { - commandText = config.History.Last10Commands[atoi] - } else { - _, _ = fmt.Fprintf(enteredCommandsView, "[#ff0000]%s %s[white]\n", time.Now().Format("04:05"), errors.Errorf("No such elements %d in command history", atoi)) - return - } - } - _, _ = fmt.Fprintf(enteredCommandsView, "%s [\"%d\"]%s[\"\"]\n", time.Now().Format("04:05"), commandsExecuted, commandText) - if err := Execute(commandText); err != nil { - _, _ = fmt.Fprintf(enteredCommandsView, "[#ff0000]%s %s[white]\n", time.Now().Format("04:05"), err) - return - } - application.QueueUpdateDraw(func() { - commandInputField.SetText("") - }) - }() - }) - commandInputField.SetAutocompleteFunc(rootCommand.Completions) - - enteredCommandsView.SetDoneFunc(func(key tcell.Key) { - currentSelection := enteredCommandsView.GetHighlights() - if key == tcell.KeyEnter { - if len(currentSelection) > 0 { - enteredCommandsView.Highlight() - } else { - enteredCommandsView.Highlight("0").ScrollToHighlight() - } - if len(currentSelection) == 1 { - commandInputField.SetText(enteredCommandsView.GetRegionText(currentSelection[0])) - application.SetFocus(commandInputField) - } - } else if len(currentSelection) > 0 { - index, _ := strconv.Atoi(currentSelection[0]) - if key == tcell.KeyTab { - index = (index + 1) % commandsExecuted - } else if key == tcell.KeyBacktab { - index = (index - 1 + commandsExecuted) % commandsExecuted - } else { - return - } - enteredCommandsView.Highlight(strconv.Itoa(index)).ScrollToHighlight() - } - }) - - commandArea.AddItem(commandInputField, 2, 0, 1, 1, 0, 0, true) - } - return commandArea -} - -func buildOutputArea(newPrimitive func(text string) tview.Primitive, application *tview.Application) *tview.Grid { - outputAreaHeader := newPrimitive("Output") - outputArea := tview.NewGrid(). - SetRows(3, 0, 10). - SetColumns(0, 30). - AddItem(outputAreaHeader, 0, 0, 1, 1, 0, 0, false) - { - var jumpToMessageItem func(messageNumber int) bool - { - outputView := tview.NewTextView(). - SetDynamicColors(true). - SetRegions(true). - SetWordWrap(false). - SetWrap(false). - SetChangedFunc(func() { - application.Draw() - }) - jumpToMessageItem = func(messageNumber int) bool { - regionId := strconv.Itoa(messageNumber) - if outputView.GetRegionText(regionId) == "" { - return false - } - outputView.Highlight(regionId).ScrollToHighlight() - return true - } - messageOutput = outputView - messageOutputClear = func() { - outputView.SetText("") - } - - outputView.SetDoneFunc(func(key tcell.Key) { - currentSelection := outputView.GetHighlights() - if key == tcell.KeyEnter { - if len(currentSelection) > 0 { - outputView.Highlight() - } else { - outputView.Highlight("0").ScrollToHighlight() - } - } else if len(currentSelection) > 0 { - index, _ := strconv.Atoi(currentSelection[0]) - if key == tcell.KeyTab { - index = (index + 1) % numberOfMessagesReceived - } else if key == tcell.KeyBacktab { - index = (index - 1 + numberOfMessagesReceived) % numberOfMessagesReceived - } else { - return - } - outputView.Highlight(strconv.Itoa(index)).ScrollToHighlight() - } - }) - outputView.SetBorder(false) - outputArea.AddItem(outputView, 1, 0, 1, 1, 0, 0, false) - } - - { - consoleView := tview.NewTextView(). - SetDynamicColors(true). - SetMaxLines(config.MaxConsoleLines). - SetChangedFunc(func() { - application.Draw() - }) - consoleOutput = consoleView - consoleOutputClear = func() { - consoleView.SetText("") - } - - consoleView.SetBorder(false) - outputArea.AddItem(consoleView, 2, 0, 1, 1, 0, 0, false) - } - - { - receivedMessagesList := tview.NewList() - messageReceived = func(messageNumber int, receiveTime time.Time, message apiModel.PlcMessage) { - application.QueueUpdateDraw(func() { - receivedMessagesList.AddItem(fmt.Sprintf("No %d @%s", messageNumber, receiveTime.Format("15:04:05.999999")), "", 0x0, func() { - if ok := jumpToMessageItem(messageNumber); !ok { - plc4xBrowserLog.Debug().Msg("Adding new message to console output") - _, _ = fmt.Fprintf(messageOutput, "Message nr: %d\n[\"%d\"]%s[\"\"]\n", messageNumber, messageNumber, message) - jumpToMessageItem(messageNumber) - } - }) - }) - } - outputArea.AddItem(receivedMessagesList, 0, 1, 3, 1, 0, 0, false) - } - } - return outputArea -} diff --git a/plc4go/tools/plc4xpcapanalyzer/cmd/analyze.go b/plc4go/tools/plc4xpcapanalyzer/cmd/analyze.go index bde4e5174..bb61b860f 100644 --- a/plc4go/tools/plc4xpcapanalyzer/cmd/analyze.go +++ b/plc4go/tools/plc4xpcapanalyzer/cmd/analyze.go @@ -23,31 +23,50 @@ import ( "math" "os" - "github.com/apache/plc4x/plc4go-extras/tools/plc4xpcapanalyzer/config" - "github.com/apache/plc4x/plc4go-extras/tools/plc4xpcapanalyzer/internal/analyzer" - - "github.com/pkg/errors" + "github.com/apache/plc4x/plc4go/spi/errors" "github.com/spf13/cobra" -) -var validProtocolType = map[string]any{ - "bacnet": nil, - "c-bus": nil, -} + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol" +) // analyzeCmd represents the analyze command var analyzeCmd = &cobra.Command{ Use: "analyze [protocolType] [pcapfile]", - Short: "analyzes a pcap file using a driver supplied driver", - Long: `Analyzes a pcap file using a driver -TODO: document me -`, + Short: "Parse, reserialize and compare every packet in a capture", + Long: `Runs a capture through a plc4x codec and reports what it cannot handle. + +Each packet's application payload is parsed, the message that comes back is re-serialized, and +the bytes are compared against the original. Three things can go wrong, and they are counted +separately because they mean different things: + + parse the codec was handed a message and could not read it + serialize the codec read the message but could not write it back + compare it wrote it back as different bytes, so reader and writer disagree + +A packet the protocol itself says is not a whole message -- a split transmission, an empty +packet, an echo -- is skipped rather than failed. A skip is not a defect and is not counted as +one. + +An interrupt stops the run and keeps the counts gathered so far. + +The protocols, with their aliases: + +` + protocol.Catalogue() + ` +The bacnet and c-bus subcommands do the same job with the protocol fixed and their own flags +available. Three of these are serial protocols -- Modbus RTU, Modbus ASCII and Firmata -- so +they only reach a capture through a gateway, and analysing one means passing --filter for +whichever port that gateway uses.`, Args: func(cmd *cobra.Command, args []string) error { + if demoRequested() { + // The demo supplies the capture, and for the generic commands the protocol too. + return nil + } if len(args) < 2 { return errors.New("requires exactly two arguments") } - if _, ok := validProtocolType[args[0]]; !ok { - return errors.Errorf("Only following protocols are supported %v", validProtocolType) + if _, err := protocol.Resolve(args[0]); err != nil { + return err } pcapFile := args[1] if _, err := os.Stat(pcapFile); errors.Is(err, os.ErrNotExist) { @@ -55,13 +74,13 @@ TODO: document me } return nil }, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { + if demoRequested() { + return analyseDemo(cmd, protocolFrom(args)) + } protocolType := args[0] pcapFile := args[1] - if err := analyzer.Analyze(pcapFile, protocolType); err != nil { - panic(err) - } - println("Done") + return analyse(cmd, pcapFile, protocolType) }, } @@ -80,4 +99,6 @@ func addAnalyzeFlags(command *cobra.Command) { command.Flags().StringVarP(&config.AnalyzeConfigInstance.Client, "client", "c", "", "The client ip (this is useful for protocols where request/response is different e.g. modbus, cbus)") command.Flags().UintVarP(&config.AnalyzeConfigInstance.StartPackageNumber, "start-package-umber", "s", 0, "Defines with what package number should be started") command.Flags().UintVarP(&config.AnalyzeConfigInstance.PackageNumberLimit, "package-number-limit", "l", math.MaxUint, "Defines how many packages should be parsed") + command.Flags().StringVarP(&config.AnalyzeConfigInstance.ReportFile, "report", "r", "", + "write a machine-readable report of what the run found: JUnit XML, or JSON if the name ends in .json") } diff --git a/plc4go/tools/plc4xpcapanalyzer/cmd/bacnet.go b/plc4go/tools/plc4xpcapanalyzer/cmd/bacnet.go index cfaa4ef49..d99cfe37b 100644 --- a/plc4go/tools/plc4xpcapanalyzer/cmd/bacnet.go +++ b/plc4go/tools/plc4xpcapanalyzer/cmd/bacnet.go @@ -22,21 +22,30 @@ package cmd import ( "os" - "github.com/apache/plc4x/plc4go-extras/tools/plc4xpcapanalyzer/config" - "github.com/apache/plc4x/plc4go-extras/tools/plc4xpcapanalyzer/internal/analyzer" - - "github.com/pkg/errors" + "github.com/apache/plc4x/plc4go/spi/errors" "github.com/spf13/cobra" + + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol" ) // bacnetCmd represents the bacnet command var bacnetCmd = &cobra.Command{ Use: "bacnet [pcapfile]", - Short: "analyzes a pcap file using a bacnet driver", - Long: `Analyzes a pcap file using a bacnet driver -TODO: document me -`, + Short: "Analyse a capture as BACnet/IP", + Long: `Runs a capture through plc4x's BACnet/IP codec: the analyze command with the protocol +fixed, and with the BACnet filter flag available. + +The default filter is "udp port 47808 and udp[4:2] > 29", which drops packets whose UDP length +is 29 bytes or less. That excludes a bare Who-Is, which is 12 bytes: pass +--default-bacnet-filter to widen it if the short messages are what you are looking for. + +An interrupt stops the run and keeps the counts gathered so far.`, Args: func(cmd *cobra.Command, args []string) error { + if demoRequested() { + // The demo supplies the capture. + return nil + } if len(args) < 1 { return errors.New("requires exactly one arguments") } @@ -46,12 +55,12 @@ TODO: document me } return nil }, - Run: func(cmd *cobra.Command, args []string) { - pcapFile := args[0] - if err := analyzer.Analyze(pcapFile, "bacnet"); err != nil { - panic(err) + RunE: func(cmd *cobra.Command, args []string) error { + if demoRequested() { + return analyseDemo(cmd, protocol.BacnetIP.Name) } - println("Done") + pcapFile := args[0] + return analyse(cmd, pcapFile, protocol.BacnetIP.Name) }, } diff --git a/plc4go/tools/plc4xpcapanalyzer/cmd/cbus.go b/plc4go/tools/plc4xpcapanalyzer/cmd/cbus.go index 98e390ec5..405b3c131 100644 --- a/plc4go/tools/plc4xpcapanalyzer/cmd/cbus.go +++ b/plc4go/tools/plc4xpcapanalyzer/cmd/cbus.go @@ -22,21 +22,32 @@ package cmd import ( "os" - "github.com/apache/plc4x/plc4go-extras/tools/plc4xpcapanalyzer/config" - "github.com/apache/plc4x/plc4go-extras/tools/plc4xpcapanalyzer/internal/analyzer" - - "github.com/pkg/errors" + "github.com/apache/plc4x/plc4go/spi/errors" "github.com/spf13/cobra" + + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol" ) // cbusCmd represents the cbus command var cbusCmd = &cobra.Command{ Use: "c-bus [pcapfile]", - Short: "analyzes a pcap file using a c-bus driver", - Long: `Analyzes a pcap file using a c-bus driver -TODO: document me -`, + Short: "Analyse a capture as Clipsal C-Bus", + Long: `Runs a capture through plc4x's C-Bus codec: the analyze command with the protocol +fixed, and with the C-Bus flags available. + +C-Bus is stateful, so how a message parses depends on the options the interface was configured +with. The --cbus-* flags describe that configuration -- connect, smart, idmon, exstat, monitor, +monall, pun, pcn and srchk -- and a capture taken from an interface in one mode will not parse +correctly as another. --default-cbus-filter changes which packets are considered at all; it +defaults to "tcp port 10001". + +An interrupt stops the run and keeps the counts gathered so far.`, Args: func(cmd *cobra.Command, args []string) error { + if demoRequested() { + // The demo supplies the capture. + return nil + } if len(args) < 1 { return errors.New("requires exactly one arguments") } @@ -46,12 +57,12 @@ TODO: document me } return nil }, - Run: func(cmd *cobra.Command, args []string) { - pcapFile := args[0] - if err := analyzer.Analyze(pcapFile, "c-bus"); err != nil { - panic(err) + RunE: func(cmd *cobra.Command, args []string) error { + if demoRequested() { + return analyseDemo(cmd, protocol.CBus.Name) } - println("Done") + pcapFile := args[0] + return analyse(cmd, pcapFile, protocol.CBus.Name) }, } diff --git a/plc4go/tools/plc4xpcapanalyzer/cmd/demo.go b/plc4go/tools/plc4xpcapanalyzer/cmd/demo.go new file mode 100644 index 000000000..87867d94b --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/cmd/demo.go @@ -0,0 +1,118 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package cmd + +import ( + "fmt" + + "github.com/pkg/errors" + "github.com/spf13/cobra" + + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/ui" +) + +// --demo on the commands that read a capture. +// +// The interface has had it since the port: it generates a small capture of real traffic and +// analyses it, so the tool can be tried out and manually debugged with nothing to hand. The +// commands that print rather than draw accepted the flag and then demanded their two arguments, +// so the only way to run them was with a real capture on disk -- which meant a machine-readable +// report, the thing most worth showing to anyone who does not already have a capture, could not +// be produced at all without one. + +// demoRequested reports whether --demo was passed. It is a persistent flag on the root, so +// every command sees it. +func demoRequested() bool { return config.RootConfigInstance.Demo } + +// demoCapture generates the demo capture and returns what to analyse, plus a cleanup. +// +// wantProtocol is the protocol the user named, or empty when the command takes it from the +// demo. Naming a protocol the demo does not speak is refused rather than obeyed: analysing +// C-Bus traffic as BACnet produces a screenful of failures that say nothing about either. +func demoCapture(cmd *cobra.Command, wantProtocol string) (protocolName, pcapFile string, cleanup func(), err error) { + demo, err := ui.NewDemo() + if err != nil { + return "", "", func() {}, err + } + cleanup = func() { _ = demo.Cleanup() } + + if wantProtocol != "" { + requested, resolveErr := protocol.Resolve(wantProtocol) + if resolveErr != nil { + cleanup() + return "", "", func() {}, resolveErr + } + if requested.Name != demo.Protocol.Name { + cleanup() + // Name the command that would work. This message is only reachable from a + // protocol-fixed subcommand, so the command to suggest is its parent. + generic := cmd.CommandPath() + if parent := cmd.Parent(); parent != nil { + generic = parent.CommandPath() + } + return "", "", func() {}, errors.Errorf( + "the demo capture is %s, so it cannot be analysed as %s: run %q without a protocol, or give a real capture", + demo.Protocol.Name, requested.Name, generic+" --demo") + } + } + + // The client address matters more than it looks. C-Bus encodes a request differently from a + // response, so without it most of the capture is read the wrong way round and what should + // be one finding is reported as six. Only when the user has not said otherwise. + if config.AnalyzeConfigInstance.Client == "" { + config.AnalyzeConfigInstance.Client = demo.Client + } + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), + "Demo mode: generated %s, a %s capture of ten packets, eight of which round-trip\n", + demo.Path, demo.Protocol.Name) + return demo.Protocol.Name, demo.Path, cleanup, nil +} + +// protocolFrom is the protocol a generic command was given, or empty when it was given none. +// With --demo the arguments are optional, so there may be nothing there. +func protocolFrom(args []string) string { + if len(args) == 0 { + return "" + } + return args[0] +} + +// analyseDemo analyses the generated capture. +func analyseDemo(cmd *cobra.Command, wantProtocol string) error { + protocolName, pcapFile, cleanup, err := demoCapture(cmd, wantProtocol) + if err != nil { + return err + } + defer cleanup() + return analyse(cmd, pcapFile, protocolName) +} + +// extractDemo extracts from the generated capture. +func extractDemo(cmd *cobra.Command, wantProtocol string) error { + protocolName, pcapFile, cleanup, err := demoCapture(cmd, wantProtocol) + if err != nil { + return err + } + defer cleanup() + return extract(cmd, pcapFile, protocolName) +} diff --git a/plc4go/tools/plc4xpcapanalyzer/cmd/extract.go b/plc4go/tools/plc4xpcapanalyzer/cmd/extract.go index 1ba55a519..f3c809b0c 100644 --- a/plc4go/tools/plc4xpcapanalyzer/cmd/extract.go +++ b/plc4go/tools/plc4xpcapanalyzer/cmd/extract.go @@ -22,26 +22,41 @@ package cmd import ( "os" - "github.com/apache/plc4x/plc4go-extras/tools/plc4xpcapanalyzer/config" - "github.com/apache/plc4x/plc4go-extras/tools/plc4xpcapanalyzer/internal/extractor" - - "github.com/pkg/errors" + "github.com/apache/plc4x/plc4go/spi/errors" "github.com/spf13/cobra" + + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol" ) // extractCmd represents the extract command var extractCmd = &cobra.Command{ Use: "extract [protocolType] [pcapfile]", - Short: "extract a pcap file using a driver supplied driver", - Long: `Extract a pcap file using a driver -TODO: document me -`, + Short: "Dump the application payloads of a capture", + Long: `Writes out the application payload of each packet in a capture, without parsing it. + +This is the step before analyze: it shows what the codec is going to be handed, which is what +you want when a capture is not being read the way you expect and the question is whether the +payloads or the codec are at fault. + +The payloads are only printed at verbosity two or above, so pass -vv. Without it the command +walks the capture and prints nothing, which looks like a failure and is not one. + +An interrupt stops the run. + +The protocols, with their aliases: + +` + protocol.Catalogue() + ``, Args: func(cmd *cobra.Command, args []string) error { + if demoRequested() { + // The demo supplies the capture, and for the generic commands the protocol too. + return nil + } if len(args) < 2 { return errors.New("requires exactly two arguments") } - if _, ok := validProtocolType[args[0]]; !ok { - return errors.Errorf("Only following protocols are supported %v", validProtocolType) + if _, err := protocol.Resolve(args[0]); err != nil { + return err } pcapFile := args[1] if _, err := os.Stat(pcapFile); errors.Is(err, os.ErrNotExist) { @@ -49,13 +64,13 @@ TODO: document me } return nil }, - Run: func(cmd *cobra.Command, args []string) { + RunE: func(cmd *cobra.Command, args []string) error { + if demoRequested() { + return extractDemo(cmd, protocolFrom(args)) + } protocolType := args[0] pcapFile := args[1] - if err := extractor.Extract(pcapFile, protocolType); err != nil { - panic(err) - } - println("Done") + return extract(cmd, pcapFile, protocolType) }, } diff --git a/plc4go/tools/plc4xpcapanalyzer/cmd/interrupt.go b/plc4go/tools/plc4xpcapanalyzer/cmd/interrupt.go new file mode 100644 index 000000000..5c8d60358 --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/cmd/interrupt.go @@ -0,0 +1,149 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package cmd + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "github.com/spf13/cobra" + + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/analyzer" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/extractor" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/finding" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/report" +) + +// reportPath is where a report should be written, or empty for none. It reads the flag through +// the configuration singleton the rest of the tool uses, so "conf set" reaches it too. +func reportPath() string { return config.AnalyzeConfigInstance.ReportFile } + +// The analysis and the extraction are reached through variables so that a test can see what +// context the command hands them. That is the thing worth pinning: the loops themselves have +// always checked their context between packets, but the entry points passed context.TODO, so +// the check could never fire and the abort was implemented and unreachable. A test that only +// reads the command's output cannot tell the difference, because the message it prints comes +// from the same context the command cancelled. +var ( + runAnalysis = analyzer.AnalyzeWithOptions + runExtractor = extractor.Extract +) + +// interruptible returns a context cancelled by the first interrupt, and a function releasing +// the handler. +// +// The analyzer and the extractor both check their context between packets and stop cleanly, +// keeping whatever they have already gathered. Nothing ever gave them a cancellable one: the +// entry points passed context.TODO and the root passes context.Background, so the abort was +// implemented and could not be asked for. A capture of any size therefore had exactly one way +// to stop, which was for the process to be killed part way through a report. +// +// Installed per command rather than on the root, and deliberately. The ui subcommand runs a +// terminal interface that reads ctrl+c as a key press and asks before it exits; cancelling its +// context from underneath would take that decision away from the user. +func interruptible(cmd *cobra.Command) (context.Context, func()) { + return signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM) +} + +// analyse is the body every analysing command shares: install the interrupt handler, run the +// analysis under the context it produces, collect what it found, and say how the run ended. +func analyse(cmd *cobra.Command, pcapFile, protocolName string) error { + ctx, release := interruptible(cmd) + defer release() + + collected := report.Report{ + Capture: pcapFile, + Protocol: protocolName, + Started: time.Now(), + } + options := analyzer.Options{Stdout: os.Stdout, Stderr: os.Stderr} + // Only collect when there is somewhere to put it. A run over a large capture would + // otherwise hold every payload in memory to no purpose. + if wanted := reportPath(); wanted != "" { + options.OnFinding = func(found finding.Finding) { + collected.Findings = append(collected.Findings, found) + } + // The analyzer's own totals rather than a second tally over the findings: the number of + // packets in the capture is a property of the capture, not of any finding, and counting + // twice is how the two paths came to disagree in the first place. + options.OnCounters = func(counters finding.Counters) { + collected.Counters = counters + } + } + + if err := runAnalysis(ctx, pcapFile, protocolName, options); err != nil { + return err + } + + // The report is written for an interrupted run too, marked as partial. What was examined + // before the interrupt is still evidence, and discarding it would make ctrl+c cost more + // than it saves. + collected.Elapsed = time.Since(collected.Started) + collected.Aborted = ctx.Err() != nil + if err := writeReport(cmd, collected); err != nil { + return err + } + + reportOutcome(cmd, ctx) + return nil +} + +// writeReport writes the report the --report flag asked for, if it asked for one. +func writeReport(cmd *cobra.Command, collected report.Report) error { + path := reportPath() + if path == "" { + return nil + } + if err := report.WriteFile(path, collected); err != nil { + return err + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Report written to %s\n", path) + return nil +} + +// extract is the same for the extracting command. +func extract(cmd *cobra.Command, pcapFile, protocolName string) error { + ctx, release := interruptible(cmd) + defer release() + + if err := runExtractor(ctx, pcapFile, protocolName); err != nil { + return err + } + reportOutcome(cmd, ctx) + return nil +} + +// reportOutcome says how a run ended, which is not always how it was asked to end. +// +// "Done" used to be printed unconditionally. Now that an interrupt actually reaches the loop, +// printing it for a run the user stopped would claim a complete report where there is a partial +// one -- the same class of untruth as a help entry for a key that does nothing. +func reportOutcome(cmd *cobra.Command, ctx context.Context) { + if ctx.Err() != nil { + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Aborted") + return + } + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Done") +} diff --git a/plc4go/tools/plc4xpcapanalyzer/cmd/interrupt_test.go b/plc4go/tools/plc4xpcapanalyzer/cmd/interrupt_test.go new file mode 100644 index 000000000..dd48d41d1 --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/cmd/interrupt_test.go @@ -0,0 +1,525 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "encoding/xml" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/analyzer" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture" +) + +// An interrupt has to reach the analysis loop, and the outcome has to say which way the run +// ended. Both were broken in the same way: the loop checked a context nothing could cancel, and +// the command printed "Done" whatever happened. + +// TestInterruptibleCancelsWithItsParent checks the returned context is derived from the +// command's, so an already-cancelled command does not start work at all. +func TestInterruptibleCancelsWithItsParent(t *testing.T) { + parent, cancel := context.WithCancel(context.Background()) + command := &cobra.Command{} + command.SetContext(parent) + + ctx, release := interruptible(command) + defer release() + require.NoError(t, ctx.Err(), "a live parent means a live context") + + cancel() + <-ctx.Done() + assert.Error(t, ctx.Err(), "cancelling the parent has to cancel the run") +} + +// TestReportOutcomeDistinguishesAnAbortFromCompletion is the honesty half. "Done" used to be +// printed unconditionally, so once an interrupt could actually stop the loop it would have +// claimed a complete report over a partial one. +func TestReportOutcomeDistinguishesAnAbortFromCompletion(t *testing.T) { + for name, test := range map[string]struct { + ctx func() context.Context + want string + }{ + "completed": { + ctx: context.Background, + want: "Done", + }, + "interrupted": { + ctx: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx + }, + want: "Aborted", + }, + } { + out := &bytes.Buffer{} + command := &cobra.Command{} + command.SetOut(out) + + reportOutcome(command, test.ctx()) + + assert.Equal(t, test.want, strings.TrimSpace(out.String()), name) + } +} + +// TestEveryCommandDocumentsItself is the same rule as the interfaces' help: a placeholder is +// worse than nothing, because it looks like documentation. Six commands shipped with "TODO: +// document me" as their long help, and the ui command's described the bacnet command instead. +func TestEveryCommandDocumentsItself(t *testing.T) { + var walk func(command *cobra.Command) + seen := 0 + walk = func(command *cobra.Command) { + if command.Name() != "help" && command.Name() != "completion" { + seen++ + t.Run(command.CommandPath(), func(t *testing.T) { + assert.NotEmpty(t, command.Short, "needs a one-line description") + require.NotEmpty(t, command.Long, "needs a long description") + for _, placeholder := range []string{"TODO", "todo", "document me", "describe me"} { + assert.NotContains(t, command.Long, placeholder, + "a placeholder reads as documentation and is worse than none") + assert.NotContains(t, command.Short, placeholder) + } + assert.Greater(t, len(command.Long), len(command.Short), + "the long description should say more than the short one") + }) + } + for _, child := range command.Commands() { + walk(child) + } + } + walk(rootCmd) + assert.Positive(t, seen, "the tree has to have commands in it") +} + +// TestEveryRunCommandHandsDownTheCommandsContext is the guard for the original defect: the +// loops have always checked their context between packets, but the entry points passed +// context.TODO, so the check could never fire. A test reading only the command's output cannot +// see that -- the message it prints comes from the same context the command cancelled -- so +// these swap the seam and inspect the context while the run is still in progress. It has to be +// inspected there: the interrupt handler is released when the command returns, which cancels +// the context, so afterwards every context looks cancelled whatever it was derived from. +func TestEveryRunCommandHandsDownTheCommandsContext(t *testing.T) { + capture := existingPcapPath(t) + + for name, test := range map[string]struct { + args []string + seam func(func(context.Context)) func() + }{ + // Four distinct commands, and distinct on purpose. "analyze bacnet" resolves to the + // bacnet subcommand rather than to analyze's own body, so reaching the generic path + // takes a protocol name that is not also a subcommand -- bacnetip. Running the same + // command twice would not work either: cobra hands a child the context from the + // previous Execute, so the second run would start already cancelled. + "analyze": {args: []string{"analyze", "bacnetip", capture}, seam: withAnalysisSeam}, + "analyze bacnet": {args: []string{"analyze", "bacnet", capture}, seam: withAnalysisSeam}, + "analyze c-bus": {args: []string{"analyze", "c-bus", capture}, seam: withAnalysisSeam}, + "extract": {args: []string{"extract", "c-bus", capture}, seam: withExtractionSeam}, + } { + t.Run(name, func(t *testing.T) { + resetContexts(rootCmd) + parent, cancel := context.WithCancel(context.Background()) + var live, derived, called bool + + restore := test.seam(func(ctx context.Context) { + called = true + live = ctx.Err() == nil + // Cancelling the command's context has to cancel the one the run was given. + // Bounded: a context that is not derived from the command's never becomes + // done, and waiting on it unbounded would hang the suite rather than fail it. + cancel() + select { + case <-ctx.Done(): + derived = true + case <-time.After(2 * time.Second): + derived = false + } + }) + defer restore() + + out := &bytes.Buffer{} + rootCmd.SetOut(out) + rootCmd.SetErr(out) + rootCmd.SetArgs(test.args) + t.Cleanup(func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + }) + + require.NoError(t, rootCmd.ExecuteContext(parent)) + require.True(t, called, "the command has to run the work at all") + assert.True(t, live, "the run has to start with a context that is not already done") + assert.True(t, derived, + "the context handed down has to be the command's, not a fresh background one") + assert.Contains(t, out.String(), "Aborted", + "and a run stopped part way has to say so rather than reporting Done") + }) + } +} + +// withAnalysisSeam replaces the analysis with an observer of the context it is handed. +func withAnalysisSeam(observe func(context.Context)) func() { + saved := runAnalysis + runAnalysis = func(ctx context.Context, _, _ string, _ analyzer.Options) error { + observe(ctx) + return nil + } + return func() { runAnalysis = saved } +} + +// withExtractionSeam does the same for the extraction. +func withExtractionSeam(observe func(context.Context)) func() { + saved := runExtractor + runExtractor = func(ctx context.Context, _, _ string) error { + observe(ctx) + return nil + } + return func() { runExtractor = saved } +} + +// --- the report flag --- + +// TestTheReportFlagWritesAJUnitReport is the feature end to end, through the command a user +// actually runs. The three TODOs this replaces sat on the three failure counters: a finding +// existed only as a log line among info lines for every packet, so a codec regression could not +// survive the run. +func TestTheReportFlagWritesAJUnitReport(t *testing.T) { + capture := realCapture(t) + path := filepath.Join(t.TempDir(), "target", "surefire-reports", "analyzer.xml") + + out := runCommand(t, []string{"analyze", "c-bus", capture, "-c", pcapfixture.ClientIP, "--report", path}) + assert.Contains(t, out, "Report written to "+path) + assert.Contains(t, out, "Done") + + document, err := os.ReadFile(path) + require.NoError(t, err, "the report has to be where it said it was") + + var parsed struct { + Tests int `xml:"tests,attr"` + Failures int `xml:"failures,attr"` + Skipped int `xml:"skipped,attr"` + Suites []struct { + Name string `xml:"name,attr"` + Properties []struct { + Name string `xml:"name,attr"` + Value string `xml:"value,attr"` + } `xml:"properties>property"` + Cases []struct { + Name string `xml:"name,attr"` + Failure *struct { + Type string `xml:"type,attr"` + Detail string `xml:",chardata"` + } `xml:"failure"` + Skipped *struct{} `xml:"skipped"` + } `xml:"testcase"` + } `xml:"testsuite"` + } + require.NoError(t, xml.Unmarshal(document, &parsed), "CI has to be able to parse it") + + // The same capture the interface's demo uses, and the same verdicts: eight clean round + // trips, one parse failure and one skip. + assert.Equal(t, 10, parsed.Tests) + assert.Equal(t, 1, parsed.Failures, "one defect, and the skip is not one") + assert.Equal(t, 1, parsed.Skipped) + + require.Len(t, parsed.Suites, 1) + assert.Equal(t, filepath.Base(capture), parsed.Suites[0].Name) + + failures := 0 + for _, one := range parsed.Suites[0].Cases { + if one.Failure == nil { + continue + } + failures++ + assert.Equal(t, "parse", one.Failure.Type) + assert.Contains(t, one.Failure.Detail, "original (", "the bytes have to be in there") + } + assert.Equal(t, 1, failures) +} + +// TestTheReportFollowsItsExtension covers the other format, and that the flag is the only thing +// choosing between them. +func TestTheReportFollowsItsExtension(t *testing.T) { + capture := realCapture(t) + path := filepath.Join(t.TempDir(), "report.json") + + runCommand(t, []string{"analyze", "c-bus", capture, "-c", pcapfixture.ClientIP, "--report", path}) + + document, err := os.ReadFile(path) + require.NoError(t, err) + + var parsed struct { + Protocol string `json:"protocol"` + Aborted bool `json:"aborted"` + Counters struct { + Walked int `json:"Walked"` + TotalInCapture int `json:"TotalInCapture"` + } `json:"counters"` + Findings []struct { + Verdict string `json:"verdict"` + Issue bool `json:"issue"` + } `json:"findings"` + } + require.NoError(t, json.Unmarshal(document, &parsed)) + + assert.Equal(t, "c-bus", parsed.Protocol) + assert.False(t, parsed.Aborted) + assert.Equal(t, 10, parsed.Counters.Walked) + assert.Equal(t, 10, parsed.Counters.TotalInCapture, + "the packet count comes from the analyzer, which is the only thing that knows it") + require.Len(t, parsed.Findings, 10) + + issues := 0 + for _, found := range parsed.Findings { + if found.Issue { + issues++ + } + } + assert.Equal(t, 1, issues) +} + +// TestNoReportIsWrittenWithoutTheFlag is the default, and the reason the findings are only +// collected when asked for: a run over a large capture would otherwise hold every payload in +// memory to no purpose. +func TestNoReportIsWrittenWithoutTheFlag(t *testing.T) { + capture := realCapture(t) + directory := t.TempDir() + + out := runCommand(t, []string{"analyze", "c-bus", capture, "-c", pcapfixture.ClientIP}) + assert.NotContains(t, out, "Report written") + + entries, err := os.ReadDir(directory) + require.NoError(t, err) + assert.Empty(t, entries, "nothing should have been written anywhere") +} + +// realCapture writes the demo capture: real C-Bus traffic, eight packets that round-trip and +// two the codec will not accept. +func realCapture(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "cbus.pcap") + require.NoError(t, pcapfixture.WriteCBus(path, pcapfixture.CBusSessionWithFailures())) + return path +} + +// runCommand runs the real command tree and returns what it printed. +func runCommand(t *testing.T, args []string) string { + t.Helper() + pinCommandGlobals(t) + resetContexts(rootCmd) + + out := &bytes.Buffer{} + rootCmd.SetOut(out) + rootCmd.SetErr(out) + rootCmd.SetArgs(args) + t.Cleanup(func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + }) + + require.NoError(t, rootCmd.ExecuteContext(context.Background())) + return out.String() +} + +// resetContexts clears the context every command in the tree is holding. +// +// cobra propagates the root's context to the command it resolved only when that command has +// none: "if cmd.ctx == nil { cmd.ctx = c.ctx }" (cobra v1.9.1, command.go:1144). So a command +// that has run once keeps that first context for the life of the process, and a test that +// cancelled one -- which the seam test above does deliberately -- leaves the next run of the +// same command starting already cancelled, reporting nothing and looking like a broken feature. +// +// nil rather than a fresh background context, for the same reason: anything non-nil is kept, +// which would leave the command with a context that never cancels and defeat the seam test. +// +// The tool itself cannot hit this, because Execute runs once per process. A test binary that +// runs the tree repeatedly hits it constantly. +func resetContexts(command *cobra.Command) { + command.SetContext(nil) + for _, child := range command.Commands() { + resetContexts(child) + } +} + +// pinCommandGlobals saves and restores the configuration singletons the commands write into, +// including the report path, which is a flag bound to one of them. +func pinCommandGlobals(t *testing.T) { + t.Helper() + savedRoot, savedPcap := config.RootConfigInstance, config.PcapConfigInstance + savedAnalyze, savedCBus := config.AnalyzeConfigInstance, config.CBusConfigInstance + savedLogger, savedLevel := log.Logger, zerolog.GlobalLevel() + t.Cleanup(func() { + config.RootConfigInstance, config.PcapConfigInstance = savedRoot, savedPcap + config.AnalyzeConfigInstance, config.CBusConfigInstance = savedAnalyze, savedCBus + log.Logger = savedLogger + zerolog.SetGlobalLevel(savedLevel) + }) + config.RootConfigInstance.HideProgressBar = true + config.AnalyzeConfigInstance.ReportFile = "" + config.AnalyzeConfigInstance.Client = "" + config.RootConfigInstance.Demo = false + config.PcapConfigInstance.PackageNumberLimit = ^uint(0) + log.Logger = zerolog.New(io.Discard) + zerolog.SetGlobalLevel(zerolog.Disabled) +} + +// --- demo mode on the commands that print --- + +// TestTheDemoNeedsNoCaptureAndNoHardware is the gap this closes. --demo is a persistent flag, +// so these commands accepted it and then demanded their two arguments: the only way to run them +// was with a real capture on disk, which meant a machine-readable report -- the thing most worth +// showing to anyone who does not already have a capture -- could not be produced without one. +func TestTheDemoNeedsNoCaptureAndNoHardware(t *testing.T) { + path := filepath.Join(t.TempDir(), "demo.xml") + out := runCommand(t, []string{"analyze", "--demo", "--report", path}) + + assert.Contains(t, out, "Demo mode", "the run has to say the capture was generated") + assert.Contains(t, out, "Done") + + document, err := os.ReadFile(path) + require.NoError(t, err, "the report has to have been written") + + var parsed struct { + Tests int `xml:"tests,attr"` + Failures int `xml:"failures,attr"` + Skipped int `xml:"skipped,attr"` + } + require.NoError(t, xml.Unmarshal(document, &parsed)) + + // The same verdicts the interface's demo shows, which is the point: one story, one answer. + assert.Equal(t, 10, parsed.Tests) + assert.Equal(t, 1, parsed.Failures) + assert.Equal(t, 1, parsed.Skipped) +} + +// TestTheDemoSuppliesTheClientAddress is the trap it exists to avoid. C-Bus encodes a request +// differently from a response, so without the client address most of the capture is read the +// wrong way round: the same ten packets report six failures instead of one, which in a demo +// looks like a broken tool. +func TestTheDemoSuppliesTheClientAddress(t *testing.T) { + path := filepath.Join(t.TempDir(), "demo.json") + runCommand(t, []string{"analyze", "--demo", "--report", path}) + + document, err := os.ReadFile(path) + require.NoError(t, err) + + var parsed struct { + Findings []struct { + Issue bool `json:"issue"` + } `json:"findings"` + } + require.NoError(t, json.Unmarshal(document, &parsed)) + + issues := 0 + for _, found := range parsed.Findings { + if found.Issue { + issues++ + } + } + assert.Equal(t, 1, issues, "without the client address this would be six") + assert.Equal(t, pcapfixture.ClientIP, config.AnalyzeConfigInstance.Client) +} + +// TestAnExplicitClientAddressSurvivesTheDemo checks the demo fills a gap rather than overruling +// the user: a value they passed has to win. +func TestAnExplicitClientAddressSurvivesTheDemo(t *testing.T) { + pinCommandGlobals(t) + resetContexts(rootCmd) + config.AnalyzeConfigInstance.Client = "10.0.0.1" + + out := &bytes.Buffer{} + rootCmd.SetOut(out) + rootCmd.SetErr(out) + rootCmd.SetArgs([]string{"analyze", "--demo"}) + t.Cleanup(func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + }) + require.NoError(t, rootCmd.ExecuteContext(context.Background())) + + assert.Equal(t, "10.0.0.1", config.AnalyzeConfigInstance.Client, + "the demo must not overwrite an address the user gave") +} + +// TestTheDemoRefusesAProtocolItCannotBe is honesty over obedience: analysing C-Bus traffic as +// BACnet produces a screenful of failures that say nothing about either, which is the worst +// possible thing to put on a screen in front of an audience. +func TestTheDemoRefusesAProtocolItCannotBe(t *testing.T) { + pinCommandGlobals(t) + resetContexts(rootCmd) + + out := &bytes.Buffer{} + rootCmd.SetOut(out) + rootCmd.SetErr(out) + rootCmd.SetArgs([]string{"analyze", "bacnet", "--demo"}) + t.Cleanup(func() { + rootCmd.SetOut(nil) + rootCmd.SetErr(nil) + rootCmd.SetArgs(nil) + }) + + err := rootCmd.ExecuteContext(context.Background()) + require.Error(t, err, "it has to refuse rather than report nonsense") + assert.Contains(t, err.Error(), "the demo capture is c-bus") + // And name a command that actually works, rather than leaving the reader at a dead end. + assert.Contains(t, err.Error(), `"plc4xpcapanalyzer analyze --demo"`) +} + +// TestTheDemoCleansUpAfterItself matters because a demo that litters the temp directory is a +// demo that gets run once. +func TestTheDemoCleansUpAfterItself(t *testing.T) { + before := demoDirectories(t) + runCommand(t, []string{"analyze", "--demo"}) + assert.ElementsMatch(t, before, demoDirectories(t), + "the generated capture has to be gone once the run is over") +} + +// TestExtractHasTheDemoToo covers the other command that reads a capture, so that trying the +// tool out does not depend on picking the right one first. +func TestExtractHasTheDemoToo(t *testing.T) { + out := runCommand(t, []string{"extract", "--demo"}) + assert.Contains(t, out, "Demo mode") + assert.Contains(t, out, "Done") +} + +// demoDirectories lists the temporary directories the demo creates, so a test can assert none +// are left behind. +func demoDirectories(t *testing.T) []string { + t.Helper() + matches, err := filepath.Glob(filepath.Join(os.TempDir(), "plc4xpcapanalyzer-demo-*")) + require.NoError(t, err) + return matches +} diff --git a/plc4go/tools/plc4xpcapanalyzer/cmd/protocolargs_test.go b/plc4go/tools/plc4xpcapanalyzer/cmd/protocolargs_test.go new file mode 100644 index 000000000..5f4a16082 --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/cmd/protocolargs_test.go @@ -0,0 +1,110 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// existingPcapPath creates an empty file so the argument validators, which only stat the path, +// get past their existence check. No capture parsing happens at this layer. +func existingPcapPath(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "capture.pcap") + require.NoError(t, os.WriteFile(path, nil, 0o600)) + return path +} + +// TestAnalyzeArgsAcceptsEveryAdvertisedProtocolSpelling is the command-layer regression test for +// the defect where "bacnet" was accepted here but rejected further down, so the run ended in a +// panic instead of an analysis. +func TestAnalyzeArgsAcceptsEveryAdvertisedProtocolSpelling(t *testing.T) { + pcapFile := existingPcapPath(t) + for _, name := range []string{"bacnet", "bacnetip", "c-bus", "cbus", "BACnet", "C-Bus"} { + t.Run(name, func(t *testing.T) { + require.NoError(t, analyzeCmd.Args(analyzeCmd, []string{name, pcapFile})) + }) + } +} + +func TestExtractArgsAcceptsEveryAdvertisedProtocolSpelling(t *testing.T) { + pcapFile := existingPcapPath(t) + for _, name := range []string{"bacnet", "bacnetip", "c-bus", "cbus"} { + t.Run(name, func(t *testing.T) { + require.NoError(t, extractCmd.Args(extractCmd, []string{name, pcapFile})) + }) + } +} + +func TestProtocolArgsRejectUnknownProtocolWithAnActionableMessage(t *testing.T) { + pcapFile := existingPcapPath(t) + for _, tc := range []struct { + name string + cmd *cobra.Command + }{ + {"analyze", analyzeCmd}, + {"extract", extractCmd}, + } { + t.Run(tc.name, func(t *testing.T) { + err := tc.cmd.Args(tc.cmd, []string{"modbus", pcapFile}) + require.Error(t, err) + // The old message printed a Go map of empty-interface values, e.g. + // "map[bacnet: c-bus:]". A user needs the real names instead. + assert.Contains(t, err.Error(), "bacnetip") + assert.Contains(t, err.Error(), "c-bus") + assert.NotContains(t, err.Error(), "", "the protocol list must not leak a Go map dump") + }) + } +} + +func TestProtocolArgsRejectMissingPcapFile(t *testing.T) { + err := analyzeCmd.Args(analyzeCmd, []string{"bacnet", filepath.Join(t.TempDir(), "absent.pcap")}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestProtocolArgsRejectTooFewArguments(t *testing.T) { + require.Error(t, analyzeCmd.Args(analyzeCmd, []string{"bacnet"})) + require.Error(t, extractCmd.Args(extractCmd, []string{"bacnet"})) +} + +// TestCommandsUseRunESoFailuresAreNotPanics pins the second half of the same defect: the +// commands used to call panic(err), so any analysis error surfaced as a goroutine dump rather +// than a CLI error message. +func TestCommandsUseRunESoFailuresAreNotPanics(t *testing.T) { + for name, cmd := range map[string]*cobra.Command{ + "analyze": analyzeCmd, + "extract": extractCmd, + "bacnet": bacnetCmd, + "cbus": cbusCmd, + "ui": uiCmd, + } { + t.Run(name, func(t *testing.T) { + assert.NotNil(t, cmd.RunE, "%s must report failures as errors", name) + assert.Nil(t, cmd.Run, "%s must not also define Run, which would shadow RunE", name) + }) + } +} diff --git a/plc4go/tools/plc4xpcapanalyzer/cmd/root.go b/plc4go/tools/plc4xpcapanalyzer/cmd/root.go index c96a304f3..1e96aa1d1 100644 --- a/plc4go/tools/plc4xpcapanalyzer/cmd/root.go +++ b/plc4go/tools/plc4xpcapanalyzer/cmd/root.go @@ -21,24 +21,41 @@ package cmd import ( "bytes" + "context" "fmt" "os" "strings" - "github.com/apache/plc4x/plc4go-extras/tools/plc4xpcapanalyzer/config" - + "github.com/charmbracelet/fang" "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/rs/zerolog/pkgerrors" "github.com/spf13/cobra" "github.com/spf13/viper" + + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config" ) // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ Use: "plc4xpcapanalyzer", - Short: "analyzes pcaps with plc4x", - Long: `todo describe me`, + Short: "Analyse captured protocol traffic with plc4x's own codecs", + Long: `plc4xpcapanalyzer replays captured protocol traffic through plc4x's own codecs and +reports what they cannot handle. + +For each packet it feeds the application payload into a real plc4x codec, re-serializes the +message that comes back, and compares the bytes against the original. A mismatch means plc4x's +reader and writer disagree about the same message, so the loop doubles as a regression harness +for the codecs themselves. + + plc4xpcapanalyzer analyze parse, reserialize and compare + plc4xpcapanalyzer extract dump the application payloads + plc4xpcapanalyzer ui [capture] the terminal interface + +Reading a capture needs libpcap, because it goes through gopacket/pcap. Run +"plc4xpcapanalyzer analyze --help" for the protocols it understands. + +With no capture to hand, "ui --demo" generates one and analyses it.`, // Uncomment the following line if your bare application // has an action associated with it: // Run: func(cmd *cobra.Command, args []string) { }, @@ -46,9 +63,19 @@ var rootCmd = &cobra.Command{ // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. +// +// fang wraps cobra to style the help and error output, and to render an Example block as a +// code block. WithoutVersion is deliberate: adding a version flag is out of scope here, and +// fang would otherwise introduce one. func Execute() { - err := rootCmd.Execute() - if err != nil { + // Before a single argument is parsed, and after every flag registration: the registrations + // live in package initialisers, so by the time Execute runs they have all written their + // defaults into the configuration singletons. Recording them here is what later lets the + // session configuration read from disk lose to a flag and win over a default -- see + // config.SnapshotDefaults. + config.SnapshotDefaults() + + if err := fang.Execute(context.Background(), rootCmd, fang.WithoutVersion()); err != nil { os.Exit(1) } } @@ -61,6 +88,8 @@ func init() { rootCmd.PersistentFlags().StringVar(&config.RootConfigInstance.LogLevel, "log-level", "error", "define the log Level") rootCmd.PersistentFlags().CountVarP(&config.RootConfigInstance.Verbosity, "verbose", "v", "counted verbosity") rootCmd.PersistentFlags().BoolVarP(&config.RootConfigInstance.HideProgressBar, "hide-progress-bar", "", false, "hides the progress bar") + rootCmd.PersistentFlags().BoolVarP(&config.RootConfigInstance.Demo, "demo", "", false, "analyze a generated sample capture instead of a file, for demos and manual debugging without a capture at hand") + rootCmd.PersistentFlags().BoolVarP(&config.RootConfigInstance.Ascii, "ascii", "", false, "force ASCII drawing characters instead of Unicode, for terminals that cannot render them") rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") } @@ -97,13 +126,13 @@ func initConfig() { w.Out = os.Stderr }, func(w *zerolog.ConsoleWriter) { - w.FormatFieldValue = func(i interface{}) string { + w.FormatFieldValue = func(i any) string { if aString, ok := i.(string); ok && strings.Contains(aString, "\\n") { return fmt.Sprintf("\x1b[%dm%v\x1b[0m", 31, "see below") } return fmt.Sprintf("%s", i) } - w.FormatExtra = func(m map[string]interface{}, buffer *bytes.Buffer) error { + w.FormatExtra = func(m map[string]any, buffer *bytes.Buffer) error { for key, i := range m { if aString, ok := i.(string); ok && strings.Contains(aString, "\n") { buffer.WriteString("\n") diff --git a/plc4go/tools/plc4xpcapanalyzer/cmd/ui.go b/plc4go/tools/plc4xpcapanalyzer/cmd/ui.go index f0a5dc14f..00cf42713 100644 --- a/plc4go/tools/plc4xpcapanalyzer/cmd/ui.go +++ b/plc4go/tools/plc4xpcapanalyzer/cmd/ui.go @@ -22,21 +22,30 @@ package cmd import ( "os" - "github.com/apache/plc4x/plc4go-extras/tools/plc4xpcapanalyzer/ui" - - "github.com/pkg/errors" - "github.com/rs/zerolog/log" + "github.com/apache/plc4x/plc4go/spi/errors" "github.com/spf13/cobra" + + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/ui" ) // uiCmd represents the ui command var uiCmd = &cobra.Command{ Use: "ui [pcapfile]", - Short: "Start the ui with optional pcapfile", - Long: `Analyzes a pcap file using a bacnet driver -TODO: document me -`, - Args: func(cmd *cobra.Command, args []string) error { + Short: "Start the terminal interface, optionally on a capture", + Long: `Opens the terminal interface, on the given capture if one is named. + +The interface does what the command line does and shows the result rather than printing it: the +packet list with a verdict against each, and a detail pane with the raw bytes, the parsed tree +and, where the round trip failed, a diff of the original bytes against the reserialized ones +with the first differing offset marked. + + --demo generate a small capture of real C-Bus traffic and analyse it immediately, so the + tool can be tried out and manually debugged with no capture and no device at hand + +Press ? for the keys. Ctrl+C stops a run in progress rather than the session; with nothing +running it offers to quit.`, + Args: func(_ *cobra.Command, args []string) error { if len(args) < 1 { return nil } @@ -46,27 +55,33 @@ TODO: document me } return nil }, - Run: func(cmd *cobra.Command, args []string) { - ui.LoadConfig() - application := ui.SetupApplication() - ui.InitSubsystem() + // RunE, not Run: the terminal UI reports a failure to start by returning it, so that cobra + // prints it and the process exits non-zero. The version this replaced could only panic. + RunE: func(cmd *cobra.Command, args []string) error { + var pcapFile string if len(args) > 0 { - pcapFile := args[0] - go func() { - err := ui.OpenFile(pcapFile) - if err != nil { - log.Error().Err(err).Msg("Error opening argument file") - } - }() - } - - defer ui.Shutdown() - if err := application.Run(); err != nil { - panic(err) + pcapFile = args[0] } + return ui.Run(cmd.Context(), ui.RunOptions{ + PcapFile: pcapFile, + // --protocol, because the interface has to start on the right one: it defaults to + // C-Bus, so a Modbus capture opened without this was analysed as C-Bus. + Protocol: uiProtocol, + // --demo generates a small capture of real C-Bus traffic and analyses it, so the + // tool can be demonstrated and manually debugged with no capture to hand. + Demo: config.RootConfigInstance.Demo, + // --ascii forces the ASCII glyph set; without it the glyph set is guessed from the + // locale. + Ascii: config.RootConfigInstance.Ascii, + }) }, } +// uiProtocol is the --protocol flag: which protocol to analyse the named capture as. +var uiProtocol string + func init() { rootCmd.AddCommand(uiCmd) + uiCmd.Flags().StringVarP(&uiProtocol, "protocol", "p", "", + "analyse the capture as this protocol; see \"analyze --help\" for the list") } diff --git a/plc4go/tools/plc4xpcapanalyzer/config/AnalyzeConfig.go b/plc4go/tools/plc4xpcapanalyzer/config/AnalyzeConfig.go index 1c6f25377..9247163c1 100644 --- a/plc4go/tools/plc4xpcapanalyzer/config/AnalyzeConfig.go +++ b/plc4go/tools/plc4xpcapanalyzer/config/AnalyzeConfig.go @@ -22,6 +22,10 @@ package config type AnalyzeConfig struct { *PcapConfig `json:"-"` NoFilter, OnlyParse, NoBytesCompare, NoCustomMapping bool + + // ReportFile is where to write a machine-readable report of what the run found, or empty + // for none. JUnit XML unless the name ends in .json, because JUnit is what CI consumes. + ReportFile string } var AnalyzeConfigInstance = AnalyzeConfig{} diff --git a/plc4go/tools/plc4xpcapanalyzer/config/RootConfig.go b/plc4go/tools/plc4xpcapanalyzer/config/RootConfig.go index 35b1730d8..d9d89fee3 100644 --- a/plc4go/tools/plc4xpcapanalyzer/config/RootConfig.go +++ b/plc4go/tools/plc4xpcapanalyzer/config/RootConfig.go @@ -25,6 +25,15 @@ type RootConfig struct { LogLevel string Verbosity int HideProgressBar bool + + // Demo runs against a generated sample capture instead of a file supplied by the user, so + // the tool can be tried out and manually debugged with no capture and no device to hand. + Demo bool + + // Ascii forces the ASCII glyph set instead of the Unicode one, for terminals, fonts or + // consoles that cannot render box-drawing characters. Left unset, the glyph set is guessed + // from the locale. + Ascii bool } var RootConfigInstance = RootConfig{} diff --git a/plc4go/tools/plc4xpcapanalyzer/config/defaults.go b/plc4go/tools/plc4xpcapanalyzer/config/defaults.go new file mode 100644 index 000000000..1daba786e --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/config/defaults.go @@ -0,0 +1,79 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package config + +// Defaults is a snapshot of the configuration singletons as the flag registrations left them. +// +// It exists to make one question answerable: did the user pass this flag? The flags bind +// straight to the singletons by pointer, so once a command line has been parsed there is +// nothing left to distinguish a value the user asked for from the default that happens to +// equal it. Without the answer, the session configuration read from disk cannot be given its +// proper place in the order -- losing to a flag and winning over a default -- and the version +// this replaces simply decoded the file over the top of everything, so a value persisted +// months ago silently beat the flag typed a second ago. +// +// Only each config's own scalar fields are meaningful here. The structs embed pointers to one +// another, so a copy shares those; each embedded config is snapshotted in its own right and is +// compared through its own entry. +type Defaults struct { + Root RootConfig + Analyze AnalyzeConfig + Extract ExtractConfig + Bacnet BacnetConfig + CBus CBusConfig + Pcap PcapConfig +} + +// defaults holds the snapshot. Zero until SnapshotDefaults is called. +var defaults Defaults + +// snapshotTaken records whether a snapshot was actually taken, so a caller can tell a real +// snapshot of zero-valued defaults from no snapshot at all. +var snapshotTaken bool + +// SnapshotDefaults records the singletons as they stand. +// +// It has to be called after every flag registration and before any command line is parsed. +// Execute is that moment: the registrations happen in package initialisers, so they are all +// done, and cobra has not yet been handed the arguments. +func SnapshotDefaults() { + defaults = Defaults{ + Root: RootConfigInstance, + Analyze: AnalyzeConfigInstance, + Extract: ExtractConfigInstance, + Bacnet: BacnetConfigInstance, + CBus: CBusConfigInstance, + Pcap: PcapConfigInstance, + } + snapshotTaken = true +} + +// Defaults returns the snapshot, and whether one was taken. +// +// With no snapshot the caller cannot identify which values came from the command line. The +// honest fallback is to treat every non-zero value as deliberate, which leaves the persisted +// configuration filling gaps rather than overriding anything -- conservative, and never +// surprising in the direction that matters. +func DefaultsSnapshot() (Defaults, bool) { return defaults, snapshotTaken } + +// ResetDefaults forgets the snapshot. For tests, which have to be able to start from nothing. +func ResetDefaults() { + defaults, snapshotTaken = Defaults{}, false +} diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/analyzer/analyzer.go b/plc4go/tools/plc4xpcapanalyzer/internal/analyzer/analyzer.go index 4bb18b0a6..73475e357 100644 --- a/plc4go/tools/plc4xpcapanalyzer/internal/analyzer/analyzer.go +++ b/plc4go/tools/plc4xpcapanalyzer/internal/analyzer/analyzer.go @@ -20,39 +20,118 @@ package analyzer import ( - "bytes" "context" "encoding/hex" "fmt" "io" "net" "os" + "strconv" "time" - "github.com/apache/plc4x/plc4go-extras/tools/plc4xpcapanalyzer/config" - "github.com/apache/plc4x/plc4go-extras/tools/plc4xpcapanalyzer/internal/bacnetanalyzer" - "github.com/apache/plc4x/plc4go-extras/tools/plc4xpcapanalyzer/internal/cbusanalyzer" - "github.com/apache/plc4x/plc4go-extras/tools/plc4xpcapanalyzer/internal/common" - "github.com/apache/plc4x/plc4go-extras/tools/plc4xpcapanalyzer/internal/pcaphandler" "github.com/apache/plc4x/plc4go/spi" - + "github.com/apache/plc4x/plc4go/spi/errors" "github.com/gopacket/gopacket" "github.com/gopacket/gopacket/layers" - "github.com/k0kubun/go-ansi" - "github.com/pkg/errors" "github.com/rs/zerolog/log" - "github.com/schollz/progressbar/v3" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/progress" + "github.com/apache/plc4x-extras/plc4go/tools/internal/tui" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/bacnetanalyzer" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/cbusanalyzer" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/codec" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/common" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/finding" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/pcaphandler" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol" ) -func Analyze(pcapFile, protocolType string) error { - return AnalyzeWithOutput(pcapFile, protocolType, os.Stdout, os.Stderr) +// Options carries the collaborators one analysis run needs: where its output goes, who watches +// the messages it parses, and who is told how far it has got. +// +// It is a struct rather than more parameters on AnalyzeWithOutputAndCallback because that +// function's signature is pinned by characterisation tests, and because the next thing to be +// hoisted out of the config singletons will belong here too. +type Options struct { + // Stdout receives the pretty-printed messages and byte dumps the verbosity flags ask for. + Stdout io.Writer + // Stderr is where a progress bar may be drawn, when Progress is unset and drawing is safe. + Stderr io.Writer + // MessageCallback, when set, is handed every successfully parsed message. + MessageCallback func(parsed spi.Message) + // OnCounters, when set, is handed the run's totals when it ends -- including when it ends + // early, in which case they describe what was examined rather than the whole capture. + // + // A caller could add up the findings itself, and the first version of the report did, but + // then the packet count in the capture is missing: it is a property of the capture rather + // than of any finding, and a report saying a ten-packet capture holds zero packets is + // worse than one saying nothing. + OnCounters func(finding.Counters) + // OnFinding, when set, is handed what every analysed packet turned out to be. + // + // This is how the analysis reports itself to something other than a log. Until it existed + // the per-packet detail -- the original payload, the re-serialized payload, and where the + // two first differ -- reached the outside world exclusively as a hex dump inside a zerolog + // field, so anything wanting to act on a finding had to either parse log lines or walk the + // capture a second time. The terminal interface does the latter to this day, and says so. + OnFinding func(finding.Finding) + // Progress receives progress. Leaving it nil means "decide from the CLI configuration", + // which is what the command line wants; a terminal UI passes progress.NewChannel so that + // it can draw progress inside its own layout instead of over the top of it. + Progress progress.Reporter + // Theme styles the default progress bar. Leaving it nil resolves one from the environment. + Theme *tui.Theme +} + +// reporter resolves the reporter this run should use. +func (o Options) reporter() progress.Reporter { + if o.Progress != nil { + return o.Progress + } + theme := tui.NewTheme(tui.OptionsFromEnv(true)) + if o.Theme != nil { + theme = *o.Theme + } + // ForWriter, not NewCLI: the bar and the zerolog stream share this descriptor, so a bar + // drawn into a redirected stderr would interleave with the log and corrupt both. That was + // the measured defect, and this is the one place the decision is now made. + return progress.ForWriter(o.Stderr, config.RootConfigInstance.HideProgressBar, theme) +} + +// Analyze runs the parse, reserialize and compare loop over a capture. +// +// The context is honoured: the loop checks it between packets and stops cleanly, keeping the +// counts it has already gathered. It used to be context.TODO here, which made the check inside +// the loop unreachable -- the abort was implemented and could never be asked for. +func Analyze(ctx context.Context, pcapFile, protocolType string) error { + return AnalyzeWithOutput(ctx, pcapFile, protocolType, os.Stdout, os.Stderr) } -func AnalyzeWithOutput(pcapFile, protocolType string, stdout, stderr io.Writer) error { - return AnalyzeWithOutputAndCallback(context.TODO(), pcapFile, protocolType, stdout, stderr, nil) +func AnalyzeWithOutput(ctx context.Context, pcapFile, protocolType string, stdout, stderr io.Writer) error { + return AnalyzeWithOutputAndCallback(ctx, pcapFile, protocolType, stdout, stderr, nil) } func AnalyzeWithOutputAndCallback(ctx context.Context, pcapFile, protocolType string, stdout, stderr io.Writer, messageCallback func(parsed spi.Message)) error { + return AnalyzeWithOptions(ctx, pcapFile, protocolType, Options{ + Stdout: stdout, + Stderr: stderr, + MessageCallback: messageCallback, + }) +} + +// AnalyzeWithOptions is the full entry point; the others are conveniences over it. +func AnalyzeWithOptions(ctx context.Context, pcapFile, protocolType string, options Options) error { + stdout := options.Stdout + if stdout == nil { + stdout = io.Discard + } + messageCallback := options.MessageCallback + reporter := options.reporter() + // Done from a defer as well: every early exit from the loop below - the package limit, a + // nil packet, a cancelled context - has to release the line the bar is holding. + defer reporter.Done() + var filterExpression = config.AnalyzeConfigInstance.Filter if filterExpression != "" { log.Info().Str("filterExpression", filterExpression).Msg("Using global filter") @@ -66,8 +145,14 @@ func AnalyzeWithOutputAndCallback(ctx context.Context, pcapFile, protocolType st _, _ = fmt.Fprintf(stdout, "%v\n", item) } var byteOutput = hex.Dump - switch protocolType { - case "bacnetip": + // Resolve the caller-supplied name onto a canonical one before dispatching. The CLI accepts + // aliases such as "bacnet"; the branches below only ever see canonical names. + proto, err := protocol.Resolve(protocolType) + if err != nil { + return err + } + switch proto.Name { + case protocol.BacnetIP.Name: if !config.AnalyzeConfigInstance.NoFilter { if config.AnalyzeConfigInstance.Filter == "" && config.BacnetConfigInstance.BacnetFilter != "" { log.Debug().Str("filter", config.BacnetConfigInstance.Filter).Msg("Setting bacnet filter") @@ -78,7 +163,7 @@ func AnalyzeWithOutputAndCallback(ctx context.Context, pcapFile, protocolType st } packageParse = bacnetanalyzer.PackageParse serializePackage = bacnetanalyzer.SerializePackage - case "c-bus": + case protocol.CBus.Name: if !config.AnalyzeConfigInstance.NoFilter { if config.AnalyzeConfigInstance.Filter == "" && config.CBusConfigInstance.CBusFilter != "" { log.Debug().Str("filter", config.CBusConfigInstance.Filter).Msg("Setting cbus filter") @@ -91,19 +176,78 @@ func AnalyzeWithOutputAndCallback(ctx context.Context, pcapFile, protocolType st analyzer.Init() packageParse = analyzer.PackageParse serializePackage = analyzer.SerializePackage - mapPackets = analyzer.MapPackets + // mappingCtx is cancelled when this function returns, which is what lets the mapping + // goroutine exit on every early exit from the loop below - the package-number limit, + // a nil packet, or a cancelled caller context. Without it that goroutine blocks on a + // send forever and keeps logging. + mappingCtx, cancelMapping := context.WithCancel(ctx) + defer func() { + cancelMapping() + // Wait for the goroutine to actually finish, not merely to be told to stop, so + // that nothing is still logging once this function has returned. + analyzer.WaitForMapping() + }() + mapPackets = func(in chan gopacket.Packet, packetInformationCreator func(packet gopacket.Packet) common.PacketInformation) chan gopacket.Packet { + return analyzer.MapPackets(mappingCtx, in, packetInformationCreator) + } if !config.AnalyzeConfigInstance.NoCustomMapping { byteOutput = analyzer.ByteOutput } else { log.Info().Msg("Custom mapping disabled") } default: - return errors.Errorf("Unsupported protocol type %s", protocolType) + // Every other protocol is a codec plus a filter, which is what internal/codec holds. + // The two branches above are the exceptions rather than the pattern: C-Bus tracks + // request context across a session and BACnet has an adapter of its own. + protocolCodec, known := codec.For(proto.Name) + if !known { + // Reachable only by adding a protocol to the name registry and nowhere else, which + // has to fail loudly rather than silently analysing nothing. + return errors.Errorf("protocol %s is registered but not implemented by the analyzer", proto.Name) + } + if !config.AnalyzeConfigInstance.NoFilter { + if config.AnalyzeConfigInstance.Filter == "" && protocolCodec.DefaultFilter() != "" { + filterExpression = protocolCodec.DefaultFilter() + log.Debug().Str("filter", filterExpression).Str("protocol", proto.Name). + Msg("Using the protocol's default filter") + } + } else { + log.Info().Msg("All filtering disabled") + } + client := net.ParseIP(config.AnalyzeConfigInstance.Client) + // Whether the direction can be established at all is a property of the capture, not of + // the run, so it is discovered per packet and reported once. A protocol read in the + // wrong direction is not read at all: every response looks like a parse failure. + guessed := false + packageParse = func(info common.PacketInformation, payload []byte) (spi.Message, error) { + response, known := protocolCodec.IsResponse(info, client) + if !known && protocolCodec.NeedsDirection { + guessed = true + } + return protocolCodec.Parse(ctx, payload, response) + } + serializePackage = func(message spi.Message) ([]byte, error) { + return protocolCodec.Serialize(ctx, message) + } + defer func() { + if guessed { + // Printed, not merely logged. The default log level is "error", so this warning was + // invisible in exactly the situation it exists for: pointed at a real Modbus capture + // with no -c, the run reported half its packets as parse failures and said nothing + // about why. Advice to the user is not a log line. + notice(options.Stderr, "%s encodes requests and responses differently, and the "+ + "direction of some packets could not be established: they were read as "+ + "requests, so any response among them will look like a parse failure. This "+ + "capture is not on the protocol's usual port, so pass -c .", + proto.Name) + log.Warn().Str("protocol", proto.Name).Msg("direction unknown for some packets") + } + }() } log.Info(). Str("pcapFile", pcapFile). - Str("protocolType", protocolType). + Str("protocolType", proto.Name). Str("filterExpression", filterExpression). Msg("Analyzing pcap file pcapFile with protocolType and filter filterExpression now") handle, numberOfPackage, timestampToIndexMap, err := pcaphandler.GetIndexedPcapHandle(pcapFile, filterExpression) @@ -114,23 +258,17 @@ func AnalyzeWithOutputAndCallback(ctx context.Context, pcapFile, protocolType st defer handle.Close() log.Debug().Interface("handle", handle).Int("numberOfPackage", numberOfPackage).Msg("got handle") source := pcaphandler.GetPacketSource(handle) - bar := progressbar.NewOptions(numberOfPackage, progressbar.OptionSetWriter(ansi.NewAnsiStderr()), - progressbar.OptionSetVisibility(!config.RootConfigInstance.HideProgressBar), - progressbar.OptionEnableColorCodes(true), - progressbar.OptionShowBytes(false), - progressbar.OptionSetWidth(15), - progressbar.OptionSetDescription("[cyan][1/3][reset] Analyzing packages..."), - progressbar.OptionSetTheme(progressbar.Theme{ - Saucer: "[green]=[reset]", - SaucerHead: "[green]>[reset]", - SaucerPadding: " ", - BarStart: "[", - BarEnd: "]", - })) + reporter.Start(numberOfPackage, "Analyzing packages") currentPackageNum := uint(0) - parseFails := 0 - serializeFails := 0 - compareFails := 0 + // One counting rule, shared with the terminal interface, and one place that reports a + // finding. The three loose integers this replaces were incremented next to a log call, so + // the totals and the detail could and did diverge from the interface's. + counters := finding.Counters{TotalInCapture: numberOfPackage} + report := func(found finding.Finding) { + if options.OnFinding != nil { + options.OnFinding(found) + } + } for packet := range mapPackets(source.Packets(), func(packet gopacket.Packet) common.PacketInformation { return createPacketInformation(pcapFile, packet, timestampToIndexMap) }) { @@ -156,9 +294,7 @@ func AnalyzeWithOutputAndCallback(ctx context.Context, pcapFile, protocolType st log.Debug().Msg("Done reading packages. (nil returned)") break } - if err := bar.Add(1); err != nil { - log.Warn().Err(err).Msg("Error updating progressBar") - } + reporter.Advance(1) packetInformation := createPacketInformation(pcapFile, packet, timestampToIndexMap) realPacketNumber := packetInformation.PacketNumber if filteredPackage, ok := packet.(common.FilteredPackage); ok { @@ -177,21 +313,36 @@ func AnalyzeWithOutputAndCallback(ctx context.Context, pcapFile, protocolType st payload := applicationLayer.Payload() if parsed, err := packageParse(packetInformation, payload); err != nil { switch { - case errors.Is(err, common.ErrUnterminatedPackage): - log.Info().Stringer("packetInformation", packetInformation). - Int("realPacketNumber", realPacketNumber). - Msg("No.[realPacketNumber] is unterminated") - case errors.Is(err, common.ErrEmptyPackage): + case errors.Is(err, common.ErrUnterminatedPackage), + errors.Is(err, common.ErrEmptyPackage), + errors.Is(err, common.ErrEcho): + // Not a defect: the protocol itself says this is not a whole message. It is + // still reported, because a report that shows only failures cannot say how + // much of the capture was actually examined. + verdict, reason := finding.Classify(err) + counters.Count(verdict) + report(finding.Finding{ + Number: realPacketNumber, + Protocol: protocolType, + Verdict: verdict, + Reason: reason, + Original: payload, + DiffOffset: -1, + }) log.Info().Stringer("packetInformation", packetInformation). Int("realPacketNumber", realPacketNumber). - Msg("No.[realPacketNumber] is empty") - case errors.Is(err, common.ErrEcho): - log.Info().Stringer("packetInformation", packetInformation). - Int("realPacketNumber", realPacketNumber). - Msg("No.[realPacketNumber] is echo") + Str("reason", reason). + Msg("No.[realPacketNumber] skipped: reason") default: - parseFails++ - // TODO: write report to xml or something + counters.Count(finding.VerdictParseFail) + report(finding.Finding{ + Number: realPacketNumber, + Protocol: protocolType, + Verdict: finding.VerdictParseFail, + Reason: err.Error(), + Original: payload, + DiffOffset: -1, + }) log.Error(). Err(err). Stringer("packetInformation", packetInformation). @@ -217,8 +368,16 @@ func AnalyzeWithOutputAndCallback(ctx context.Context, pcapFile, protocolType st } serializedBytes, err := serializePackage(parsed) if err != nil { - serializeFails++ - // TODO: write report to xml or something + counters.Count(finding.VerdictSerializeFail) + report(finding.Finding{ + Number: realPacketNumber, + Protocol: protocolType, + Summary: finding.MessageName(parsed), + Verdict: finding.VerdictSerializeFail, + Reason: err.Error(), + Original: payload, + DiffOffset: -1, + }) log.Warn(). Err(err). Stringer("packetInformation", packetInformation). @@ -230,9 +389,18 @@ func AnalyzeWithOutputAndCallback(ctx context.Context, pcapFile, protocolType st log.Trace().Msg("not comparing bytes") continue } - if compareResult := bytes.Compare(payload, serializedBytes); compareResult != 0 { - compareFails++ - // TODO: write report to xml or something + if offset := finding.FirstDifference(payload, serializedBytes); offset >= 0 { + counters.Count(finding.VerdictBytesDiffer) + report(finding.Finding{ + Number: realPacketNumber, + Protocol: protocolType, + Summary: finding.MessageName(parsed), + Verdict: finding.VerdictBytesDiffer, + Reason: strconv.Itoa(finding.DifferingBytes(payload, serializedBytes)) + " bytes differ", + Original: payload, + Reserialized: serializedBytes, + DiffOffset: offset, + }) log.Warn(). Stringer("packetInformation", packetInformation). Int("realPacketNumber", realPacketNumber). @@ -242,20 +410,61 @@ func AnalyzeWithOutputAndCallback(ctx context.Context, pcapFile, protocolType st if config.AnalyzeConfigInstance.Verbosity > 0 { _, _ = fmt.Fprintf(stdout, "Original bytes\n%s\n%s\n", hex.Dump(payload), hex.Dump(serializedBytes)) } + } else { + counters.Count(finding.VerdictOK) + report(finding.Finding{ + Number: realPacketNumber, + Protocol: protocolType, + Summary: finding.MessageName(parsed), + Verdict: finding.VerdictOK, + Original: payload, + Reserialized: serializedBytes, + DiffOffset: -1, + }) } } } + if options.OnCounters != nil { + options.OnCounters(counters) + } + // The field names are the log's contract -- a characterisation test reads them, and so may + // anything parsing these logs -- so they stay as they are while the values now come from + // the shared counters. log.Info(). Uint("currentPackageNum", currentPackageNum). Int("numberOfPackage", numberOfPackage). - Int("parseFails", parseFails). - Int("serializeFails", serializeFails). - Int("compareFails", compareFails). + Int("parseFails", counters.ParseFail). + Int("serializeFails", counters.SerializeFail). + Int("compareFails", counters.CompareFail). Msg("Done evaluating currentPackageNum of numberOfPackage packages (parseFails failed to parse, serializeFails failed to serialize and compareFails failed in byte comparison)") return nil } +// notice tells the user something they need to act on, whatever the log level is set to. +// +// It goes to the run's stderr rather than through the logger because the two have different +// audiences: the log is for working out afterwards what happened, and a notice is for the person +// waiting at the terminal now. A notice that a log level can hide is not a notice. +func notice(stderr io.Writer, format string, args ...any) { + if stderr == nil { + return + } + _, _ = fmt.Fprintf(stderr, "warning: "+format+"\n", args...) +} + +// isResponse reports whether a packet travelled from the device to the client. +// +// Unknown when there is no client address, and "request" is the honest default then: it is what +// the protocols' own reference vectors mostly are, and guessing the other way would make a +// capture look worse than it is. +func isResponse(info common.PacketInformation, client net.IP) bool { + if client == nil || info.SrcIp == nil { + return false + } + return !info.SrcIp.Equal(client) +} + func createPacketInformation(pcapFile string, packet gopacket.Packet, timestampToIndexMap map[time.Time]int) common.PacketInformation { packetTimestamp := packet.Metadata().Timestamp realPacketNumber := timestampToIndexMap[packetTimestamp] @@ -269,5 +478,11 @@ func createPacketInformation(pcapFile string, packet gopacket.Packet, timestampT packetInformation.SrcIp = networkLayer.SrcIP packetInformation.DstIp = networkLayer.DstIP } + switch transport := packet.TransportLayer().(type) { + case *layers.TCP: + packetInformation.SrcPort, packetInformation.DstPort = int(transport.SrcPort), int(transport.DstPort) + case *layers.UDP: + packetInformation.SrcPort, packetInformation.DstPort = int(transport.SrcPort), int(transport.DstPort) + } return packetInformation } diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/analyzer/analyzer_characterisation_test.go b/plc4go/tools/plc4xpcapanalyzer/internal/analyzer/analyzer_characterisation_test.go new file mode 100644 index 000000000..090b973fb --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/internal/analyzer/analyzer_characterisation_test.go @@ -0,0 +1,308 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package analyzer_test + +import ( + "bytes" + "context" + "encoding/json" + "io" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/analyzer" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol" +) + +// These are characterisation tests. They describe what the analyzer does TODAY, driven by real +// protocol bytes, so that the Bubble Tea port and the refactor that gives Analyze a real return +// value can be checked against the behaviour they are supposed to preserve. +// +// The counters are currently only observable as fields on one zerolog line, so that is what +// these tests read. When Analyze grows a Stats return value, these assertions should be +// re-pointed at it and must produce the same numbers. + +// syncBuffer is a mutex-guarded log sink. +// +// It is guarded for a specific reason: the C-Bus analyzer's MapPackets starts a producer +// goroutine and leaks it whenever the consumer stops early, which is exactly what the +// package-number-limit test does. That orphaned goroutine keeps logging through the global +// logger, so it can write into a later test's buffer. Locking keeps that from being a data +// race; the stray lines themselves are harmless here because we select the summary line by +// content. Removing the leak is part of the analyzer refactor. +type syncBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *syncBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +// summary is the tail of the analyzer's final log line. +type summary struct { + CurrentPackageNum int `json:"currentPackageNum"` + NumberOfPackage int `json:"numberOfPackage"` + ParseFails int `json:"parseFails"` + SerializeFails int `json:"serializeFails"` + CompareFails int `json:"compareFails"` +} + +// runAnalyze runs the analyzer against a capture and returns its final counters. +// +// It has to save and restore the package-level config singletons and the global logger, +// because the analyzer reads its entire configuration from them. That is precisely the +// coupling the port is meant to remove; until then, tests have to work around it. +func runAnalyze(t *testing.T, pcapFile, protocolName string, configure func()) (summary, error) { + got, found, err := runAnalyzeRaw(t, pcapFile, protocolName, configure) + if err == nil { + require.True(t, found, "a successful analysis must report a summary line") + } + return got, err +} + +// runAnalyzeRaw is runAnalyze without the expectation that a summary was produced, for the +// cases that fail before the analyzer reaches its summary. +func runAnalyzeRaw(t *testing.T, pcapFile, protocolName string, configure func()) (summary, bool, error) { + t.Helper() + + savedPcap := config.PcapConfigInstance + savedAnalyze := config.AnalyzeConfigInstance + savedRoot := config.RootConfigInstance + savedCBus := config.CBusConfigInstance + savedLogger := log.Logger + savedLevel := zerolog.GlobalLevel() + t.Cleanup(func() { + config.PcapConfigInstance = savedPcap + config.AnalyzeConfigInstance = savedAnalyze + config.RootConfigInstance = savedRoot + config.CBusConfigInstance = savedCBus + log.Logger = savedLogger + zerolog.SetGlobalLevel(savedLevel) + }) + + // A progress bar would write escape codes to the real stderr during the test run. + config.RootConfigInstance.HideProgressBar = true + configure() + + logs := &syncBuffer{} + log.Logger = zerolog.New(logs).Level(zerolog.InfoLevel) + zerolog.SetGlobalLevel(zerolog.InfoLevel) + + err := analyzer.AnalyzeWithOutput(t.Context(), pcapFile, protocolName, io.Discard, io.Discard) + + got, found := lastSummary(t, logs.String()) + return got, found, err +} + +// lastSummary finds the analyzer's closing log line and decodes its counters. It reports +// whether such a line was present, since a run that fails early never emits one. +func lastSummary(t *testing.T, logOutput string) (summary, bool) { + t.Helper() + var got summary + var found bool + // Take the LAST matching line: a leaked goroutine from an earlier test may have written an + // older summary into this buffer. + for line := range strings.SplitSeq(logOutput, "\n") { + if !strings.Contains(line, "Done evaluating") { + continue + } + var candidate summary + require.NoError(t, json.Unmarshal([]byte(line), &candidate), "summary line should be JSON: %s", line) + got, found = candidate, true + } + return got, found +} + +// TestAnalyzeCBusFixtureCharacterisation records the counters for a capture in which every +// payload is known to parse and re-serialize identically. +func TestAnalyzeCBusFixtureCharacterisation(t *testing.T) { + pcapFile := filepath.Join(t.TempDir(), "cbus.pcap") + session := pcapfixture.CBusSession() + require.NoError(t, pcapfixture.WriteCBus(pcapFile, session)) + + got, err := runAnalyze(t, pcapFile, protocol.CBus.Name, func() { + config.PcapConfigInstance.Client = pcapfixture.ClientIP + config.AnalyzeConfigInstance.NoFilter = true + config.PcapConfigInstance.PackageNumberLimit = ^uint(0) + }) + require.NoError(t, err) + + assert.Equal(t, len(session), got.NumberOfPackage, "every fixture packet should be seen") + assert.Equal(t, len(session), got.CurrentPackageNum, "every packet should be walked") + assert.Zero(t, got.ParseFails, "every fixture payload is known to parse") + assert.Zero(t, got.SerializeFails, "every fixture payload is known to serialize") + assert.Zero(t, got.CompareFails, "every fixture payload is known to round-trip byte-identically") +} + +// TestAnalyzeCBusCountsParseFailures proves the parse-failure counter actually counts, using +// payloads verified not to parse. Without this, a refactor could zero the counter unnoticed. +func TestAnalyzeCBusCountsParseFailures(t *testing.T) { + pcapFile := filepath.Join(t.TempDir(), "cbus-broken.pcap") + session := pcapfixture.CBusSessionWithFailures() + require.NoError(t, pcapfixture.WriteCBus(pcapFile, session)) + + got, err := runAnalyze(t, pcapFile, protocol.CBus.Name, func() { + config.PcapConfigInstance.Client = pcapfixture.ClientIP + config.AnalyzeConfigInstance.NoFilter = true + config.PcapConfigInstance.PackageNumberLimit = ^uint(0) + }) + require.NoError(t, err) + + assert.Equal(t, len(session), got.NumberOfPackage) + assert.Positive(t, got.ParseFails, "the two unparseable payloads must be counted") +} + +// TestAnalyzeBacnetFixtureCharacterisation is the regression test for the defect that made +// every BACnet analysis fail. Both the canonical name and the alias must now analyse the +// capture instead of returning an unsupported-protocol error. +func TestAnalyzeBacnetFixtureCharacterisation(t *testing.T) { + for _, name := range []string{"bacnetip", "bacnet"} { + t.Run(name, func(t *testing.T) { + pcapFile := filepath.Join(t.TempDir(), "bacnet.pcap") + session := pcapfixture.BacnetSession() + require.NoError(t, pcapfixture.WriteBacnet(pcapFile, session)) + + got, err := runAnalyze(t, pcapFile, name, func() { + // Filtering off: the default BACnet filter drops packets whose UDP length is + // 29 bytes or less, which would silently discard the Who-Is fixtures. + config.AnalyzeConfigInstance.NoFilter = true + config.PcapConfigInstance.PackageNumberLimit = ^uint(0) + }) + require.NoError(t, err, "%q must be analysable; it used to fail with an unsupported-protocol error", name) + + assert.Equal(t, len(session), got.NumberOfPackage) + assert.Zero(t, got.ParseFails, "every BACnet fixture payload is known to parse") + assert.Zero(t, got.CompareFails, "every BACnet fixture payload round-trips byte-identically") + }) + } +} + +// TestAnalyzeRejectsUnknownProtocolWithoutTouchingTheCapture documents that protocol +// resolution now happens before any file access, so a typo cannot look like a capture problem. +func TestAnalyzeRejectsUnknownProtocolWithoutTouchingTheCapture(t *testing.T) { + _, _, err := runAnalyzeRaw(t, filepath.Join(t.TempDir(), "does-not-exist.pcap"), "modbus", func() {}) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported protocol") + assert.NotContains(t, err.Error(), "open offline", "the protocol should be rejected before the capture is opened") +} + +// TestAnalyzePackageNumberLimitStopsEarly characterises the limit flag, which the TUI sets on +// every run and which the port must keep honouring. +func TestAnalyzePackageNumberLimitStopsEarly(t *testing.T) { + pcapFile := filepath.Join(t.TempDir(), "cbus.pcap") + session := pcapfixture.CBusSession() + require.NoError(t, pcapfixture.WriteCBus(pcapFile, session)) + + const limit = 3 + got, err := runAnalyze(t, pcapFile, protocol.CBus.Name, func() { + config.PcapConfigInstance.Client = pcapfixture.ClientIP + config.AnalyzeConfigInstance.NoFilter = true + config.PcapConfigInstance.PackageNumberLimit = limit + }) + require.NoError(t, err) + + assert.Equal(t, limit+1, got.CurrentPackageNum, + "the loop increments before testing the limit, so it stops one past it; pinned deliberately") + assert.Less(t, got.CurrentPackageNum, len(session), "the limit must actually cut the run short") +} + +// TestACancelledContextStopsTheAnalysis is the half of the abort that lives here. The check +// between packets has always been in the loop; what was missing was any way to reach it, because +// the entry points passed context.TODO. The evidence is the analyzer's own summary: a cancelled +// run reports a capture of eight packets and none walked, where an unhonoured cancellation +// would walk all eight. +// +// It runs in this package rather than through the command, because cobra.OnInitialize replaces +// the global logger before a command's body runs and takes the summary with it. +func TestACancelledContextStopsTheAnalysis(t *testing.T) { + capture := filepath.Join(t.TempDir(), "cbus.pcap") + require.NoError(t, pcapfixture.WriteCBus(capture, pcapfixture.CBusSession())) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + got, found, err := runAnalyzeWith(t, ctx, capture, "c-bus") + + assert.NoError(t, err, "an interrupt is not a failure") + require.True(t, found, "the analyzer still reports what it managed to do") + assert.Positive(t, got.NumberOfPackage, "the capture has packets in it to walk") + assert.Zero(t, got.CurrentPackageNum, + "a run cancelled before it started must not walk the capture") +} + +// TestALiveContextRunsToTheEnd is the control. Without it the test above would pass just as well +// against an analyzer that had stopped working altogether. +func TestALiveContextRunsToTheEnd(t *testing.T) { + capture := filepath.Join(t.TempDir(), "cbus.pcap") + require.NoError(t, pcapfixture.WriteCBus(capture, pcapfixture.CBusSession())) + + got, found, err := runAnalyzeWith(t, context.Background(), capture, "c-bus") + + require.NoError(t, err) + require.True(t, found) + assert.Equal(t, got.NumberOfPackage, got.CurrentPackageNum, + "a live context has to let the whole capture through") +} + +// runAnalyzeWith is runAnalyzeRaw with the context chosen by the caller, which is the one thing +// the other helpers cannot vary. +func runAnalyzeWith(t *testing.T, ctx context.Context, pcapFile, protocolName string) (summary, bool, error) { + t.Helper() + + savedRoot, savedCBus := config.RootConfigInstance, config.CBusConfigInstance + savedPcap, savedAnalyze := config.PcapConfigInstance, config.AnalyzeConfigInstance + savedLogger, savedLevel := log.Logger, zerolog.GlobalLevel() + t.Cleanup(func() { + config.RootConfigInstance, config.CBusConfigInstance = savedRoot, savedCBus + config.PcapConfigInstance, config.AnalyzeConfigInstance = savedPcap, savedAnalyze + log.Logger = savedLogger + zerolog.SetGlobalLevel(savedLevel) + }) + config.RootConfigInstance.HideProgressBar = true + // No flags are parsed here, so the limit is its zero value and the loop would stop after + // the first packet -- which would make the control below indistinguishable from the abort. + config.PcapConfigInstance.PackageNumberLimit = ^uint(0) + + logs := &syncBuffer{} + log.Logger = zerolog.New(logs).Level(zerolog.InfoLevel) + zerolog.SetGlobalLevel(zerolog.InfoLevel) + + err := analyzer.AnalyzeWithOutput(ctx, pcapFile, protocolName, io.Discard, io.Discard) + got, found := lastSummary(t, logs.String()) + return got, found, err +} diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/analyzer/analyzer_progress_test.go b/plc4go/tools/plc4xpcapanalyzer/internal/analyzer/analyzer_progress_test.go new file mode 100644 index 000000000..92412bb5f --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/internal/analyzer/analyzer_progress_test.go @@ -0,0 +1,212 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package analyzer_test + +import ( + "io" + "path/filepath" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/progress" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/analyzer" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol" +) + +// These tests exist because the progress wiring had no regression protection: deleting the +// reporter.Start and reporter.Advance calls from the analysis loop left every other test in +// the tree green. The reporter is the whole point of the Options.Progress seam, so it needs a +// test that fails when the analyser stops driving it. + +// recordingReporter records the calls an analysis makes, so a test can assert on them. +// +// It is mutex-guarded because a reporter is fair game to call from whichever goroutine is +// doing the work, and the race detector runs over this package. +type recordingReporter struct { + mu sync.Mutex + + starts []recordedStart + advanced int + advanceCalls int + descriptions []string + doneCalls int +} + +type recordedStart struct { + total int + description string +} + +func (r *recordingReporter) Start(total int, description string) { + r.mu.Lock() + defer r.mu.Unlock() + r.starts = append(r.starts, recordedStart{total: total, description: description}) +} + +func (r *recordingReporter) Advance(delta int) { + r.mu.Lock() + defer r.mu.Unlock() + r.advanced += delta + r.advanceCalls++ +} + +func (r *recordingReporter) SetDescription(description string) { + r.mu.Lock() + defer r.mu.Unlock() + r.descriptions = append(r.descriptions, description) +} + +func (r *recordingReporter) Done() { + r.mu.Lock() + defer r.mu.Unlock() + r.doneCalls++ +} + +func (r *recordingReporter) snapshot() recordingReporter { + r.mu.Lock() + defer r.mu.Unlock() + return recordingReporter{ + starts: append([]recordedStart(nil), r.starts...), + advanced: r.advanced, + advanceCalls: r.advanceCalls, + descriptions: append([]string(nil), r.descriptions...), + doneCalls: r.doneCalls, + } +} + +// runWithReporter analyses a fixture with a recording reporter attached, restoring the +// package-level configuration the analyser still reads from. +func runWithReporter(t *testing.T, pcapFile, protocolName string, configure func()) (recordingReporter, error) { + t.Helper() + + savedPcap := config.PcapConfigInstance + savedAnalyze := config.AnalyzeConfigInstance + savedRoot := config.RootConfigInstance + t.Cleanup(func() { + config.PcapConfigInstance = savedPcap + config.AnalyzeConfigInstance = savedAnalyze + config.RootConfigInstance = savedRoot + }) + config.RootConfigInstance.HideProgressBar = true + configure() + + reporter := &recordingReporter{} + err := analyzer.AnalyzeWithOptions(t.Context(), pcapFile, protocolName, analyzer.Options{ + Stdout: io.Discard, + Stderr: io.Discard, + Progress: reporter, + }) + return reporter.snapshot(), err +} + +// TestAnalyzeDrivesTheProgressReporter is the guard the reviewer asked for: it fails if the +// analysis loop stops announcing its size or stops advancing. +func TestAnalyzeDrivesTheProgressReporter(t *testing.T) { + pcapFile := filepath.Join(t.TempDir(), "cbus.pcap") + session := pcapfixture.CBusSession() + require.NoError(t, pcapfixture.WriteCBus(pcapFile, session)) + + recorded, err := runWithReporter(t, pcapFile, protocol.CBus.Name, func() { + config.PcapConfigInstance.Client = pcapfixture.ClientIP + config.AnalyzeConfigInstance.NoFilter = true + config.PcapConfigInstance.PackageNumberLimit = ^uint(0) + }) + require.NoError(t, err) + + require.NotEmpty(t, recorded.starts, "the analyser must announce the work it is about to do") + assert.Equal(t, len(session), recorded.starts[0].total, + "Start must carry the packet count, which is what sizes the bar") + assert.NotEmpty(t, recorded.starts[0].description, "a bar with no description says nothing") + + assert.Equal(t, len(session), recorded.advanced, + "every packet walked must advance the reporter exactly once") + assert.Equal(t, len(session), recorded.advanceCalls) + assert.Positive(t, recorded.doneCalls, "the analyser must finish the bar rather than abandon it") +} + +func TestAnalyzeDrivesTheProgressReporterForBacnetToo(t *testing.T) { + pcapFile := filepath.Join(t.TempDir(), "bacnet.pcap") + session := pcapfixture.BacnetSession() + require.NoError(t, pcapfixture.WriteBacnet(pcapFile, session)) + + recorded, err := runWithReporter(t, pcapFile, protocol.BacnetIP.Name, func() { + config.AnalyzeConfigInstance.NoFilter = true + config.PcapConfigInstance.PackageNumberLimit = ^uint(0) + }) + require.NoError(t, err) + + require.NotEmpty(t, recorded.starts) + assert.Equal(t, len(session), recorded.starts[0].total) + assert.Equal(t, len(session), recorded.advanced) + assert.Positive(t, recorded.doneCalls) +} + +// TestAnalyzeFinishesTheReporterEvenWhenItStopsEarly matters because the package-number limit +// breaks out of the loop. A bar left unfinished is exactly the "stuck at 60%" artefact the +// reporter is supposed to avoid. +func TestAnalyzeFinishesTheReporterEvenWhenItStopsEarly(t *testing.T) { + pcapFile := filepath.Join(t.TempDir(), "cbus.pcap") + session := pcapfixture.CBusSession() + require.NoError(t, pcapfixture.WriteCBus(pcapFile, session)) + + const limit = 3 + recorded, err := runWithReporter(t, pcapFile, protocol.CBus.Name, func() { + config.PcapConfigInstance.Client = pcapfixture.ClientIP + config.AnalyzeConfigInstance.NoFilter = true + config.PcapConfigInstance.PackageNumberLimit = limit + }) + require.NoError(t, err) + + assert.Positive(t, recorded.doneCalls, "an early exit must still finish the bar") + assert.Less(t, recorded.advanced, len(session), "the limit must actually cut the run short") + assert.LessOrEqual(t, recorded.advanced, limit+1, + "the loop advances before testing the limit, so it may overshoot by one") +} + +// TestAnalyzeToleratesTheNopReporter pins that progress really is optional: the default path +// must not depend on a reporter being attached. +func TestAnalyzeToleratesTheNopReporter(t *testing.T) { + pcapFile := filepath.Join(t.TempDir(), "cbus.pcap") + require.NoError(t, pcapfixture.WriteCBus(pcapFile, pcapfixture.CBusSession())) + + savedPcap := config.PcapConfigInstance + savedAnalyze := config.AnalyzeConfigInstance + savedRoot := config.RootConfigInstance + t.Cleanup(func() { + config.PcapConfigInstance = savedPcap + config.AnalyzeConfigInstance = savedAnalyze + config.RootConfigInstance = savedRoot + }) + config.RootConfigInstance.HideProgressBar = true + config.PcapConfigInstance.Client = pcapfixture.ClientIP + config.AnalyzeConfigInstance.NoFilter = true + config.PcapConfigInstance.PackageNumberLimit = ^uint(0) + + assert.NoError(t, analyzer.AnalyzeWithOptions(t.Context(), pcapFile, protocol.CBus.Name, analyzer.Options{ + Stdout: io.Discard, + Stderr: io.Discard, + Progress: progress.Nop(), + })) +} diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/analyzer/protocols_test.go b/plc4go/tools/plc4xpcapanalyzer/internal/analyzer/protocols_test.go new file mode 100644 index 000000000..1858ecf42 --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/internal/analyzer/protocols_test.go @@ -0,0 +1,309 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package analyzer_test + +import ( + "bytes" + "context" + "io" + "path/filepath" + "slices" + "testing" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/analyzer" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/codec" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/finding" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol" +) + +// Every protocol, against its own reference vectors. +// +// The payloads come from each protocol's ParserSerializerTestsuite in the Apache PLC4X repo, +// which plc4x asserts round-trips. So the analyzer -- whose entire job is to measure that +// property -- has to report a capture built from them as completely clean. Anything else means +// the adapter here is wrong, or plc4x has regressed, and both are worth failing a build over. +// +// This is what makes the support real rather than merely compiled. It is also why the byte +// order is in the codec: parsed through the default big-endian buffer, not one of EtherNet/IP's +// twenty-seven vectors survives, which would have shipped as "the codec cannot parse its own +// protocol". + +// protocolCase is one protocol's fixture. +type protocolCase struct { + protocol string + wire pcapfixture.Wire + packets []pcapfixture.Packet +} + +func protocolCases() []protocolCase { + return []protocolCase{ + {protocol.AbEth.Name, pcapfixture.AbEthWire, pcapfixture.AbEthSession()}, + {protocol.Ads.Name, pcapfixture.AdsWire, pcapfixture.AdsSession()}, + {protocol.Eip.Name, pcapfixture.EipWire, pcapfixture.EipSession()}, + {protocol.Firmata.Name, pcapfixture.FirmataWire, pcapfixture.FirmataSession()}, + {protocol.KnxNetIp.Name, pcapfixture.KnxWire, pcapfixture.KnxSession()}, + {protocol.ModbusTcp.Name, pcapfixture.ModbusWire, pcapfixture.ModbusSession()}, + {protocol.ModbusAscii.Name, pcapfixture.ModbusAsciiWire, pcapfixture.ModbusAsciiSession()}, + {protocol.ModbusRtu.Name, pcapfixture.ModbusRtuWire, pcapfixture.ModbusRtuSession()}, + {protocol.S7.Name, pcapfixture.S7Wire, pcapfixture.S7Session()}, + {protocol.Slmp.Name, pcapfixture.SlmpWire, pcapfixture.SlmpSession()}, + } +} + +// TestEveryProtocolRoundTripsItsOwnReferenceVectors is the test that makes the support real. +func TestEveryProtocolRoundTripsItsOwnReferenceVectors(t *testing.T) { + for _, test := range protocolCases() { + t.Run(test.protocol, func(t *testing.T) { + require.NotEmpty(t, test.packets, "a protocol with no vectors proves nothing") + + capture := filepath.Join(t.TempDir(), test.protocol+".pcap") + require.NoError(t, pcapfixture.Write(capture, test.wire, test.packets)) + + findings, counters := analyseFixture(t, capture, test.protocol) + + require.Len(t, findings, len(test.packets), + "every packet in the capture has to be examined; the default filter may be dropping them") + assert.Equal(t, len(test.packets), counters.Walked) + assert.Zero(t, counters.Issues(), + "these payloads round-trip by construction, so any finding is a defect here or in plc4x") + + for i, found := range findings { + assert.Equal(t, finding.VerdictOK, found.Verdict, + "%s packet %d (%s): %s", test.protocol, i+1, test.packets[i].Comment, found.Reason) + assert.NotEmpty(t, found.Summary, "a parsed message has to name its type") + assert.Equal(t, test.packets[i].Payload, found.Original) + assert.Equal(t, test.packets[i].Payload, found.Reserialized, + "the whole point: what the codec wrote back has to be what it read") + } + }) + } +} + +// TestEveryCodecIsRegisteredUnderAKnownName holds the two registries to each other. A codec +// under a name the CLI does not accept is unreachable; a name with no codec is a promise the +// analyzer cannot keep, and the switch's default says so at runtime rather than at build time. +func TestEveryCodecIsRegisteredUnderAKnownName(t *testing.T) { + for _, name := range codec.Names() { + resolved, err := protocol.Resolve(name) + require.NoError(t, err, "codec %q is not a protocol the command line accepts", name) + assert.Equal(t, name, resolved.Name, "a codec has to be keyed by the canonical name") + } + + // And the other direction: every name the CLI offers is analysable, by a codec or by one of + // the two adapters that predate this package. + adapters := []string{protocol.BacnetIP.Name, protocol.CBus.Name} + for _, known := range protocol.All() { + _, hasCodec := codec.For(known.Name) + assert.True(t, hasCodec || slices.Contains(adapters, known.Name), + "%s is offered by the command line but nothing can analyse it", known.Name) + } +} + +// TestEveryProtocolCaseIsCovered guards the fixture list against drifting from the registry -- +// a protocol added to the codec registry without vectors would be untested support, which is +// the thing this whole exercise exists to avoid. +func TestEveryProtocolCaseIsCovered(t *testing.T) { + covered := make([]string, 0, len(protocolCases())) + for _, test := range protocolCases() { + covered = append(covered, test.protocol) + } + for _, name := range codec.Names() { + assert.Contains(t, covered, name, + "%s has a codec but no reference vectors, so nothing shows it works", name) + } +} + +// analyseFixture runs the analyzer over a capture and returns what it found. +func analyseFixture(t *testing.T, capture, protocolName string) ([]finding.Finding, finding.Counters) { + t.Helper() + + savedRoot, savedPcap := config.RootConfigInstance, config.PcapConfigInstance + savedAnalyze := config.AnalyzeConfigInstance + savedLogger, savedLevel := log.Logger, zerolog.GlobalLevel() + t.Cleanup(func() { + config.RootConfigInstance, config.PcapConfigInstance = savedRoot, savedPcap + config.AnalyzeConfigInstance = savedAnalyze + log.Logger = savedLogger + zerolog.SetGlobalLevel(savedLevel) + }) + + config.RootConfigInstance.HideProgressBar = true + // The vectors' own client address, so the protocols that encode the two directions + // differently are read the right way round. + config.AnalyzeConfigInstance.Client = pcapfixture.ClientIP + // No flags are parsed here, so the limit is its zero value and the loop would stop after + // the first packet. + config.PcapConfigInstance.PackageNumberLimit = ^uint(0) + log.Logger = zerolog.New(io.Discard) + zerolog.SetGlobalLevel(zerolog.Disabled) + + var findings []finding.Finding + var counters finding.Counters + err := analyzer.AnalyzeWithOptions(context.Background(), capture, protocolName, analyzer.Options{ + OnFinding: func(one finding.Finding) { findings = append(findings, one) }, + OnCounters: func(all finding.Counters) { counters = all }, + }) + require.NoError(t, err) + return findings, counters +} + +// TestTheUsualPortNeedsNoClientAddress is the headline of how direction is decided. A packet +// leaving the protocol's registered port came from the device; one arriving at it came from the +// client. That is how Wireshark decides, it is right per packet, and it asks the user nothing. +// +// It replaced being told the client's address, which was a footgun: pointed at a real Modbus +// capture without -c, the tool read every response as a request and reported half the file as +// parse failures. +func TestTheUsualPortNeedsNoClientAddress(t *testing.T) { + capture := filepath.Join(t.TempDir(), "modbus.pcap") + require.NoError(t, pcapfixture.Write(capture, pcapfixture.ModbusWire, pcapfixture.ModbusSession())) + + stderr := &bytes.Buffer{} + findings, counters := analyseWithStderr(t, capture, protocol.ModbusTcp.Name, "", stderr, false) + + assert.Empty(t, stderr.String(), "nothing to advise: the port settled every packet") + assert.Zero(t, counters.Issues(), + "the responses have to have been read as responses, or they would all have failed") + require.Len(t, findings, len(pcapfixture.ModbusSession())) + for i, found := range findings { + assert.Equal(t, finding.VerdictOK, found.Verdict, "packet %d: %s", i+1, found.Reason) + } +} + +// TestAnUnusualPortFallsBackAndSaysSo is the other half. A capture taken somewhere the protocol +// does not normally live -- a tunnel, a gateway -- tells the port nothing, so the client address +// is needed and its absence has to be announced rather than logged: the default log level is +// "error", so a log line was invisible in exactly the situation it exists for. +func TestAnUnusualPortFallsBackAndSaysSo(t *testing.T) { + // The same payloads, somewhere Modbus does not live. + tunnelled := pcapfixture.Wire{Transport: pcapfixture.TCP, Port: 15020} + capture := filepath.Join(t.TempDir(), "tunnelled.pcap") + require.NoError(t, pcapfixture.Write(capture, tunnelled, pcapfixture.ModbusSession())) + + t.Run("without a client address", func(t *testing.T) { + stderr := &bytes.Buffer{} + _, counters := analyseWithStderr(t, capture, protocol.ModbusTcp.Name, "", stderr, true) + + assert.Contains(t, stderr.String(), "encodes requests and responses differently") + assert.Contains(t, stderr.String(), "-c ", "it has to say what to do") + assert.Positive(t, counters.Issues(), + "and the failures it is explaining have to actually be there") + }) + + t.Run("with a client address", func(t *testing.T) { + stderr := &bytes.Buffer{} + _, counters := analyseWithStderr(t, capture, protocol.ModbusTcp.Name, pcapfixture.ClientIP, stderr, true) + + assert.Empty(t, stderr.String(), "the address answered the question, so there is nothing to say") + assert.Zero(t, counters.Issues(), "and it answered it correctly") + }) +} + +// TestADirectionlessProtocolSaysNothing keeps the notice from becoming noise: KNXNet/IP and the +// rest do not care which way a packet went, so telling their users about -c would be wrong. +func TestADirectionlessProtocolSaysNothing(t *testing.T) { + tunnelled := pcapfixture.Wire{Transport: pcapfixture.UDP, Port: 13671} + capture := filepath.Join(t.TempDir(), "knx.pcap") + require.NoError(t, pcapfixture.Write(capture, tunnelled, pcapfixture.KnxSession())) + + stderr := &bytes.Buffer{} + analyseWithStderr(t, capture, protocol.KnxNetIp.Name, "", stderr, true) + assert.Empty(t, stderr.String(), "KNXNet/IP needs no direction, so there is nothing to say") +} + +// analyseWithStderr runs the analyzer with the given client address and captures its stderr. +func analyseWithStderr(t *testing.T, capture, protocolName, client string, stderr io.Writer, + noFilter bool) ([]finding.Finding, finding.Counters) { + t.Helper() + + savedRoot, savedPcap := config.RootConfigInstance, config.PcapConfigInstance + savedAnalyze := config.AnalyzeConfigInstance + savedLogger, savedLevel := log.Logger, zerolog.GlobalLevel() + t.Cleanup(func() { + config.RootConfigInstance, config.PcapConfigInstance = savedRoot, savedPcap + config.AnalyzeConfigInstance = savedAnalyze + log.Logger = savedLogger + zerolog.SetGlobalLevel(savedLevel) + }) + config.RootConfigInstance.HideProgressBar = true + config.AnalyzeConfigInstance.Client = client + config.PcapConfigInstance.PackageNumberLimit = ^uint(0) + // A capture written somewhere the protocol does not live would be thrown away by the + // protocol's own default filter, which is not what these tests are about. + config.AnalyzeConfigInstance.NoFilter = noFilter + log.Logger = zerolog.New(io.Discard) + zerolog.SetGlobalLevel(zerolog.Disabled) + + var findings []finding.Finding + var counters finding.Counters + require.NoError(t, analyzer.AnalyzeWithOptions(context.Background(), capture, protocolName, + analyzer.Options{ + Stderr: stderr, + OnFinding: func(one finding.Finding) { findings = append(findings, one) }, + OnCounters: func(all finding.Counters) { counters = all }, + })) + return findings, counters +} + +// TestModbusExceptionResponsesRoundTrip pins a plc4x fix that this test used to record as a defect. +// +// Modbus flags an error by setting the top bit of the original function code, so 0x03 becomes 0x83 +// and 0x08 becomes 0x88. plc4x used to write 0x80 back whatever went in: the typeSwitch case for +// ModbusPDUError matches on the error bit alone, which left the code generator with no constant for +// the function code, so it emitted a zero. Its own parser read the function correctly and its own +// serializer then discarded it. Fixed upstream in the generator, which now retains a discriminator +// value that no sub type pins. +// +// The exchange stays here as a regression vector rather than being deleted with the defect, because +// these are real exception responses and nothing else in the suite covers them. A failure here means +// either the fix was lost in a plc4go bump or the analyzer has started reporting clean traffic. +func TestModbusExceptionResponsesRoundTrip(t *testing.T) { + packets := pcapfixture.ModbusExceptionSession() + capture := filepath.Join(t.TempDir(), "exceptions.pcap") + require.NoError(t, pcapfixture.Write(capture, pcapfixture.ModbusWire, packets)) + + findings, counters := analyseFixture(t, capture, protocol.ModbusTcp.Name) + require.Len(t, findings, len(packets)) + assert.Equal(t, 0, counters.Issues()) + + exceptions := 0 + for _, found := range findings { + assert.Equal(t, finding.VerdictOK, found.Verdict, + "packet %d: %s", found.Number, found.Reason) + require.Equal(t, found.Original, found.Reserialized, "packet %d", found.Number) + + // The two responses are the point: the error bit and the function code both survive, + // where the function code used to be replaced by a zero. + if len(found.Original) > 7 && found.Original[7]&0x80 != 0 { + exceptions++ + assert.NotZero(t, found.Original[7]&0x7f, "the vector has to carry a function code") + assert.Equal(t, found.Original[7], found.Reserialized[7], + "the function code that failed has to survive re-serialization") + } + } + require.Equal(t, 2, exceptions, "the fixture has to still contain both exception responses") +} diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/bacnetanalyzer/analyzer.go b/plc4go/tools/plc4xpcapanalyzer/internal/bacnetanalyzer/analyzer.go index cf0661765..ab66be8a8 100644 --- a/plc4go/tools/plc4xpcapanalyzer/internal/bacnetanalyzer/analyzer.go +++ b/plc4go/tools/plc4xpcapanalyzer/internal/bacnetanalyzer/analyzer.go @@ -22,17 +22,17 @@ package bacnetanalyzer import ( "context" - "github.com/apache/plc4x/plc4go-extras/tools/plc4xpcapanalyzer/internal/common" - "github.com/apache/plc4x/plc4go/protocols/bacnetip/readwrite/model" + readWriteModel "github.com/apache/plc4x/plc4go/protocols/bacnetip/readwrite/model" "github.com/apache/plc4x/plc4go/spi" - - "github.com/pkg/errors" + "github.com/apache/plc4x/plc4go/spi/errors" "github.com/rs/zerolog/log" + + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/common" ) func PackageParse(packetInformation common.PacketInformation, payload []byte) (spi.Message, error) { log.Debug().Stringer("packetInformation", packetInformation).Msg("Parsing") - parse, err := model.BVLCParse(context.TODO(), payload) + parse, err := readWriteModel.BVLCParse[readWriteModel.BVLC](context.TODO(), payload) if err != nil { return nil, errors.Wrap(err, "Error parsing bvlc") } @@ -41,7 +41,7 @@ func PackageParse(packetInformation common.PacketInformation, payload []byte) (s } func SerializePackage(bvlc spi.Message) ([]byte, error) { - if bvlc, ok := bvlc.(model.BVLC); !ok { + if bvlc, ok := bvlc.(readWriteModel.BVLC); !ok { log.Fatal().Type("bvlc", bvlc).Msg("Unsupported type supplied") panic("unreachable statement") } else { diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/cbusanalyzer/analyzer.go b/plc4go/tools/plc4xpcapanalyzer/internal/cbusanalyzer/analyzer.go index 6b40f208f..8dd5d98c3 100644 --- a/plc4go/tools/plc4xpcapanalyzer/internal/cbusanalyzer/analyzer.go +++ b/plc4go/tools/plc4xpcapanalyzer/internal/cbusanalyzer/analyzer.go @@ -25,15 +25,15 @@ import ( "net" "reflect" - "github.com/apache/plc4x/plc4go-extras/tools/plc4xpcapanalyzer/config" - "github.com/apache/plc4x/plc4go-extras/tools/plc4xpcapanalyzer/internal/common" readWriteModel "github.com/apache/plc4x/plc4go/protocols/cbus/readwrite/model" "github.com/apache/plc4x/plc4go/spi" - + "github.com/apache/plc4x/plc4go/spi/errors" "github.com/gopacket/gopacket" - "github.com/pkg/errors" "github.com/rs/zerolog" "github.com/rs/zerolog/log" + + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/common" ) type Analyzer struct { @@ -44,6 +44,7 @@ type Analyzer struct { currentInboundPayloads map[string][]byte currentPrefilterInboundPayloads map[string][]byte mappedPacketChan chan gopacket.Packet + mappingDone chan struct{} lastParsePayload []byte lastMapPayload []byte @@ -98,9 +99,9 @@ func (a *Analyzer) PackageParse(packetInformation common.PacketInformation, payl return nil, common.ErrEcho } a.lastParsePayload = currentPayload - parse, err := readWriteModel.CBusMessageParse(context.TODO(), currentPayload, isResponse, a.requestContext, cBusOptions) + parse, err := readWriteModel.CBusMessageParse[readWriteModel.CBusMessage](context.TODO(), currentPayload, isResponse, a.requestContext, cBusOptions) if err != nil { - if secondParse, err := readWriteModel.CBusMessageParse(context.TODO(), currentPayload, isResponse, readWriteModel.NewRequestContext(false), readWriteModel.NewCBusOptions(false, false, false, false, false, false, false, false, false)); err != nil { + if secondParse, err := readWriteModel.CBusMessageParse[readWriteModel.CBusMessage](context.TODO(), currentPayload, isResponse, readWriteModel.NewRequestContext(false), readWriteModel.NewCBusOptions(false, false, false, false, false, false, false, false, false)); err != nil { log.Debug().Err(err).Msg("Second parse failed too") return nil, errors.Wrap(err, "Error parsing CBusCommand") } else { @@ -276,21 +277,33 @@ func (a *Analyzer) SerializePackage(message spi.Message) ([]byte, error) { } } -// MapPackets reorders the packages as they were not split -func (a *Analyzer) MapPackets(in chan gopacket.Packet, packetInformationCreator func(packet gopacket.Packet) common.PacketInformation) chan gopacket.Packet { +// MapPackets reorders the packages as they were not split. +// +// The returned channel is fed by a goroutine, which stops as soon as ctx is done. That matters +// because a consumer is allowed to stop reading early - the analyzer does exactly that when it +// hits the package-number limit - and an unguarded send would then block this goroutine for +// the lifetime of the process. A leaked goroutine here is not merely idle: it keeps writing to +// the global logger, which is a data race against anyone replacing that logger. +func (a *Analyzer) MapPackets(ctx context.Context, in chan gopacket.Packet, packetInformationCreator func(packet gopacket.Packet) common.PacketInformation) chan gopacket.Packet { if a.mappedPacketChan == nil { a.mappedPacketChan = make(chan gopacket.Packet) + a.mappingDone = make(chan struct{}) go func() { + defer close(a.mappingDone) defer close(a.mappedPacketChan) mappingLoop: for packet := range in { switch { case packet == nil: log.Debug().Msg("Done reading packages. (nil returned)") - a.mappedPacketChan <- nil + // The nil marker tells the consumer the capture is exhausted. Whether or + // not it still reads, this goroutine is finished either way. + a.send(ctx, nil) break mappingLoop case packet.ApplicationLayer() == nil: - a.mappedPacketChan <- packet + if !a.send(ctx, packet) { + break mappingLoop + } default: packetInformation := packetInformationCreator(packet) mergeCallback := func(index int) { @@ -300,7 +313,9 @@ func (a *Analyzer) MapPackets(in chan gopacket.Packet, packetInformationCreator } if payload, err := a.getCurrentPayload(packetInformation, packet.ApplicationLayer().Payload(), mergeCallback, a.currentPrefilterInboundPayloads, &a.lastMapPayload); err != nil { log.Debug().Err(err).Stringer("packetInformation", packetInformation).Msg("Filtering message") - a.mappedPacketChan <- common.NewFilteredPackage(err, packet) + if !a.send(ctx, common.NewFilteredPackage(err, packet)) { + break mappingLoop + } } else { currentApplicationLayer := packet.ApplicationLayer() newPayload := gopacket.Payload(payload) @@ -312,7 +327,9 @@ func (a *Analyzer) MapPackets(in chan gopacket.Packet, packetInformationCreator packet = &manipulatedPackage{Packet: packet, newApplicationLayer: newPayload} } a.lastMapPayload = payload - a.mappedPacketChan <- packet + if !a.send(ctx, packet) { + break mappingLoop + } } } } @@ -321,6 +338,31 @@ func (a *Analyzer) MapPackets(in chan gopacket.Packet, packetInformationCreator return a.mappedPacketChan } +// WaitForMapping blocks until the mapping goroutine has exited. Callers should cancel the +// context they passed to MapPackets first. +// +// Waiting is not optional hygiene: the goroutine logs as it unwinds, so a caller that returns +// without waiting leaves a goroutine writing to the global logger after the call is over. Any +// later change to that logger is then a data race, which is how this was found. +func (a *Analyzer) WaitForMapping() { + if a.mappingDone == nil { + return + } + <-a.mappingDone +} + +// send delivers packet downstream, reporting false if ctx was cancelled first so the caller +// can stop instead of blocking on a channel nobody is reading any more. +func (a *Analyzer) send(ctx context.Context, packet gopacket.Packet) bool { + select { + case a.mappedPacketChan <- packet: + return true + case <-ctx.Done(): + log.Debug().Err(ctx.Err()).Msg("Stopping packet mapping because the consumer went away") + return false + } +} + // ByteOutput returns the string representation as usually this is ASCII over serial... so this output is much more useful in that context func (a *Analyzer) ByteOutput(data []byte) string { return fmt.Sprintf("%+q\n", data) @@ -344,37 +386,37 @@ func CreateRequestContextWithInfoCallback(cBusMessage readWriteModel.CBusMessage infoCallBack = func(_ string) {} } switch cBusMessage := cBusMessage.(type) { - case readWriteModel.CBusMessageToServerExactly: + case readWriteModel.CBusMessageToServer: switch request := cBusMessage.GetRequest().(type) { - case readWriteModel.RequestDirectCommandAccessExactly: + case readWriteModel.RequestDirectCommandAccess: sendIdentifyRequestBefore := false infoCallBack("CAL request detected") switch request.GetCalData().(type) { - case readWriteModel.CALDataIdentifyExactly: + case readWriteModel.CALDataIdentify: sendIdentifyRequestBefore = true } return readWriteModel.NewRequestContext(sendIdentifyRequestBefore) - case readWriteModel.RequestCommandExactly: + case readWriteModel.RequestCommand: switch command := request.GetCbusCommand().(type) { - case readWriteModel.CBusCommandPointToPointExactly: + case readWriteModel.CBusCommandPointToPoint: sendIdentifyRequestBefore := false infoCallBack("CAL request detected") switch command.GetCommand().GetCalData().(type) { - case readWriteModel.CALDataIdentifyExactly: + case readWriteModel.CALDataIdentify: sendIdentifyRequestBefore = true } return readWriteModel.NewRequestContext(sendIdentifyRequestBefore) } - case readWriteModel.RequestObsoleteExactly: + case readWriteModel.RequestObsolete: sendIdentifyRequestBefore := false infoCallBack("CAL request detected") switch request.GetCalData().(type) { - case readWriteModel.CALDataIdentifyExactly: + case readWriteModel.CALDataIdentify: sendIdentifyRequestBefore = true } return readWriteModel.NewRequestContext(sendIdentifyRequestBefore) } - case readWriteModel.CBusMessageToClientExactly: + case readWriteModel.CBusMessageToClient: // We received a request, so we need to reset our flags return readWriteModel.NewRequestContext(false) } diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/codec/codec.go b/plc4go/tools/plc4xpcapanalyzer/internal/codec/codec.go new file mode 100644 index 000000000..aed28c3e7 --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/internal/codec/codec.go @@ -0,0 +1,225 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +// Package codec is the parse-and-re-serialize pair for each protocol the analyzer understands. +// +// It exists so that supporting a protocol is a data change rather than another branch. The +// analyzer used to name its two protocols in a switch, and the comment on that switch's default +// said as much: adding one to the name registry without adding a branch had to fail loudly, +// because there was nowhere else for the knowledge to live. +// +// Three things vary between protocols and all three are mechanical, which is why this works: +// the parse function, the byte order its buffers use, and the BPF filter that selects its +// packets. Two protocols need more than that -- C-Bus tracks request context across a session +// and BACnet has an adapter of its own -- and those stay where they are rather than being bent +// into this shape. +package codec + +import ( + "context" + "encoding/binary" + "net" + "strconv" + + abethModel "github.com/apache/plc4x/plc4go/protocols/abeth/readwrite/model" + adsModel "github.com/apache/plc4x/plc4go/protocols/ads/readwrite/model" + eipModel "github.com/apache/plc4x/plc4go/protocols/eip/readwrite/model" + firmataModel "github.com/apache/plc4x/plc4go/protocols/firmata/readwrite/model" + knxModel "github.com/apache/plc4x/plc4go/protocols/knxnetip/readwrite/model" + modbusModel "github.com/apache/plc4x/plc4go/protocols/modbus/readwrite/model" + s7Model "github.com/apache/plc4x/plc4go/protocols/s7/readwrite/model" + slmpModel "github.com/apache/plc4x/plc4go/protocols/slmp/readwrite/model" + "github.com/apache/plc4x/plc4go/spi" + "github.com/apache/plc4x/plc4go/spi/utils" + "github.com/pkg/errors" + + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/common" +) + +// parseFunc reads one message from a buffer. response says the payload travelled from the +// device to the client, which several protocols need because they encode the two directions +// differently -- and which is why the analyzer wants a client address. +type parseFunc func(ctx context.Context, buffer utils.ReadBufferByteBased, response bool) (spi.Message, error) + +// Codec is what the analyzer needs in order to examine one protocol. +type Codec struct { + // ByteOrder is the order this protocol's buffers read and write in. + // + // It is not decoration. EtherNet/IP is little-endian, and parsed through the default + // big-endian buffer not one of Apache's twenty-seven reference vectors survives the round + // trip -- they fail at the first field, which reads as a codec that cannot parse its own + // protocol rather than as a buffer configured wrongly. + ByteOrder binary.ByteOrder + // Transport and ServerPort are where the protocol lives: the device listens on ServerPort. + // + // They do two jobs. They generate the default filter, so the filter and the port cannot + // disagree. And they settle the direction of a packet without asking anyone: one arriving + // at ServerPort is a request and one leaving it is a response, which is how Wireshark + // decides. Being told the client's address is the fallback, not the mechanism. + Transport string + ServerPort int + // NeedsDirection says the parse depends on which way the packet travelled. Such a protocol + // read in the wrong direction is not read at all: every response looks like a parse failure. + NeedsDirection bool + + parse parseFunc +} + +// DefaultFilter selects this protocol's packets when the user gives no filter of their own. +func (c Codec) DefaultFilter() string { + if c.Transport == "" || c.ServerPort == 0 { + return "" + } + return c.Transport + " port " + strconv.Itoa(c.ServerPort) +} + +// IsResponse says whether a packet travelled from the device to the client, and whether that +// could be established at all. +// +// The port first, because it needs nothing from the user and is right per packet: a packet +// leaving the protocol's registered port came from the device. The client address second, for a +// capture taken somewhere else -- a tunnel, a non-standard port -- where the port says nothing. +// When neither settles it the answer is "request", because that is what most of a capture is and +// what the protocols' own reference vectors mostly are, and the caller is told it was a guess so +// it can say so out loud. +// +// Deliberately not attempted: parsing both ways and keeping whichever succeeds. A payload can +// parse validly as both a request and a response, so that would silently pick one and report a +// confident round trip for a message it had read wrongly. In a tool whose only output is whether +// something round-trips, a plausible wrong answer is worse than a failure. +func (c Codec) IsResponse(info common.PacketInformation, client net.IP) (response, known bool) { + if c.ServerPort != 0 { + switch { + case info.SrcPort == c.ServerPort: + return true, true + case info.DstPort == c.ServerPort: + return false, true + } + } + if client != nil && info.SrcIp != nil { + return !info.SrcIp.Equal(client), true + } + return false, false +} + +// Parse reads one message from a payload. +func (c Codec) Parse(ctx context.Context, payload []byte, response bool) (spi.Message, error) { + buffer := utils.NewReadBufferByteBased(payload, utils.WithByteOrderForReadBufferByteBased(c.ByteOrder)) + message, err := c.parse(ctx, buffer, response) + if err != nil { + return nil, err + } + return message, nil +} + +// Serialize writes a message back out, in the same byte order it was read in. +func (c Codec) Serialize(ctx context.Context, message spi.Message) ([]byte, error) { + buffer := utils.NewWriteBufferByteBased(utils.WithByteOrderForByteBasedBuffer(c.ByteOrder)) + if err := message.SerializeWithWriteBuffer(ctx, buffer); err != nil { + return nil, errors.Wrap(err, "error serializing") + } + return buffer.GetBytes(), nil +} + +// codecs are the protocols this package can examine, by canonical protocol name. +// +// The filters are the protocols' registered ports. A protocol carried somewhere else -- Modbus +// RTU tunnelled over TCP, say -- needs the user's own --filter, which is why the serial ones +// are here at all: their transport is not IP, so they only ever reach a capture through a +// gateway, and only the user knows which port that gateway uses. +var codecs = map[string]Codec{ + "modbus-tcp": { + ByteOrder: binary.BigEndian, Transport: "tcp", ServerPort: 502, NeedsDirection: true, + parse: modbusParse(modbusModel.DriverType_MODBUS_TCP), + }, + "modbus-rtu": { + ByteOrder: binary.BigEndian, Transport: "tcp", ServerPort: 502, NeedsDirection: true, + parse: modbusParse(modbusModel.DriverType_MODBUS_RTU), + }, + "modbus-ascii": { + ByteOrder: binary.BigEndian, Transport: "tcp", ServerPort: 502, NeedsDirection: true, + parse: modbusParse(modbusModel.DriverType_MODBUS_ASCII), + }, + "s7": { + ByteOrder: binary.BigEndian, Transport: "tcp", ServerPort: 102, + parse: func(ctx context.Context, buffer utils.ReadBufferByteBased, _ bool) (spi.Message, error) { + return s7Model.TPKTPacketParseWithBuffer(ctx, buffer) + }, + }, + "eip": { + ByteOrder: binary.LittleEndian, Transport: "tcp", ServerPort: 44818, NeedsDirection: true, + parse: func(ctx context.Context, buffer utils.ReadBufferByteBased, response bool) (spi.Message, error) { + return eipModel.EipPacketParseWithBuffer[eipModel.EipPacket](ctx, buffer, response) + }, + }, + "knxnet-ip": { + ByteOrder: binary.BigEndian, Transport: "udp", ServerPort: 3671, + parse: func(ctx context.Context, buffer utils.ReadBufferByteBased, _ bool) (spi.Message, error) { + return knxModel.KnxNetIpMessageParseWithBuffer[knxModel.KnxNetIpMessage](ctx, buffer) + }, + }, + "ads": { + ByteOrder: binary.LittleEndian, Transport: "tcp", ServerPort: 48898, + parse: func(ctx context.Context, buffer utils.ReadBufferByteBased, _ bool) (spi.Message, error) { + return adsModel.AmsTCPPacketParseWithBuffer(ctx, buffer) + }, + }, + "ab-eth": { + ByteOrder: binary.BigEndian, Transport: "tcp", ServerPort: 2222, + parse: func(ctx context.Context, buffer utils.ReadBufferByteBased, _ bool) (spi.Message, error) { + return abethModel.CIPEncapsulationPacketParseWithBuffer[abethModel.CIPEncapsulationPacket](ctx, buffer) + }, + }, + "slmp": { + ByteOrder: binary.LittleEndian, Transport: "tcp", ServerPort: 5007, + parse: func(ctx context.Context, buffer utils.ReadBufferByteBased, _ bool) (spi.Message, error) { + return slmpModel.SlmpMessageParseWithBuffer[slmpModel.SlmpMessage](ctx, buffer) + }, + }, + "firmata": { + ByteOrder: binary.BigEndian, Transport: "tcp", ServerPort: 3030, NeedsDirection: true, + parse: func(ctx context.Context, buffer utils.ReadBufferByteBased, response bool) (spi.Message, error) { + return firmataModel.FirmataMessageParseWithBuffer[firmataModel.FirmataMessage](ctx, buffer, response) + }, + }, +} + +// modbusParse is the Modbus parse for one of its three framings. The three differ only in the +// driver type they are told, which is what makes one entry serve TCP, RTU and ASCII. +func modbusParse(driver modbusModel.DriverType) parseFunc { + return func(ctx context.Context, buffer utils.ReadBufferByteBased, response bool) (spi.Message, error) { + return modbusModel.ModbusADUParseWithBuffer[modbusModel.ModbusADU](ctx, buffer, driver, response) + } +} + +// For returns the codec for a canonical protocol name. +func For(name string) (Codec, bool) { + found, ok := codecs[name] + return found, ok +} + +// Names lists the protocols this package can examine, for a test that wants to hold the +// registry and the name registry to each other. +func Names() []string { + names := make([]string, 0, len(codecs)) + for name := range codecs { + names = append(names, name) + } + return names +} diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/codec/codec_test.go b/plc4go/tools/plc4xpcapanalyzer/internal/codec/codec_test.go new file mode 100644 index 000000000..081fd6248 --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/internal/codec/codec_test.go @@ -0,0 +1,134 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package codec + +import ( + "net" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/common" +) + +// How a packet's direction is decided. It matters more than it sounds: a protocol that encodes +// requests and responses differently, read in the wrong direction, is not read at all -- every +// response comes back as a parse failure, which is what a real Modbus capture did before this. + +// TestThePortDecidesFirst is the mechanism, and the reason nothing has to be configured. A +// packet leaving the protocol's registered port came from the device; one arriving at it came +// from the client. That is per-packet and needs nothing from the user. +func TestThePortDecidesFirst(t *testing.T) { + modbus, ok := For("modbus-tcp") + require.True(t, ok) + require.Equal(t, 502, modbus.ServerPort) + + device := net.ParseIP("10.0.0.2") + client := net.ParseIP("10.0.0.1") + + toDevice := common.PacketInformation{SrcIp: client, DstIp: device, SrcPort: 40000, DstPort: 502} + fromDevice := common.PacketInformation{SrcIp: device, DstIp: client, SrcPort: 502, DstPort: 40000} + + response, known := modbus.IsResponse(toDevice, nil) + assert.False(t, response, "a packet arriving at the device's port is a request") + assert.True(t, known) + + response, known = modbus.IsResponse(fromDevice, nil) + assert.True(t, response, "a packet leaving the device's port is a response") + assert.True(t, known) + + // And the port wins over a client address that says otherwise: the port is a fact about + // the packet, an address given on a command line is a claim about the capture. + response, known = modbus.IsResponse(fromDevice, device) + assert.True(t, response, "the port is the better evidence") + assert.True(t, known) +} + +// TestTheClientAddressIsTheFallback covers a capture taken somewhere the protocol does not +// normally live -- a tunnel, a gateway -- where the port says nothing. +func TestTheClientAddressIsTheFallback(t *testing.T) { + modbus, ok := For("modbus-tcp") + require.True(t, ok) + + client := net.ParseIP("10.0.0.1") + device := net.ParseIP("10.0.0.2") + // Neither end is on 502. + tunnelled := common.PacketInformation{SrcIp: device, DstIp: client, SrcPort: 15020, DstPort: 40000} + + response, known := modbus.IsResponse(tunnelled, client) + assert.True(t, response, "from anyone but the client is a response") + assert.True(t, known) + + fromClient := common.PacketInformation{SrcIp: client, DstIp: device, SrcPort: 40000, DstPort: 15020} + response, known = modbus.IsResponse(fromClient, client) + assert.False(t, response) + assert.True(t, known) +} + +// TestNeitherIsAdmittedRatherThanGuessedSilently is the honesty of the thing. With no port and +// no address the answer is "request", because that is what most of a capture is -- but the +// caller is told it was a guess, so it can say so out loud instead of reporting failures it +// could have explained. +func TestNeitherIsAdmittedRatherThanGuessedSilently(t *testing.T) { + modbus, ok := For("modbus-tcp") + require.True(t, ok) + + unknown := common.PacketInformation{SrcPort: 15020, DstPort: 40000} + response, known := modbus.IsResponse(unknown, nil) + assert.False(t, response, "a request is the honest default") + assert.False(t, known, "and the caller has to be able to tell that it was a guess") +} + +// TestTheFilterComesFromThePort is why the two cannot disagree. They were separate fields, and +// a filter naming one port while the direction logic used another would drop the packets it was +// meant to classify. +func TestTheFilterComesFromThePort(t *testing.T) { + for name, want := range map[string]string{ + "modbus-tcp": "tcp port 502", + "s7": "tcp port 102", + "eip": "tcp port 44818", + "knxnet-ip": "udp port 3671", + "ads": "tcp port 48898", + "ab-eth": "tcp port 2222", + "slmp": "tcp port 5007", + } { + found, ok := For(name) + require.True(t, ok, name) + assert.Equal(t, want, found.DefaultFilter(), name) + } + + // A codec with nowhere to live has no filter rather than a malformed one. + assert.Empty(t, Codec{}.DefaultFilter()) +} + +// TestEveryCodecCanBeFilteredAndDirected guards the registry: a codec with no port has no +// default filter and no way to decide direction, which would make it quietly worse than the +// others rather than visibly unfinished. +func TestEveryCodecCanBeFilteredAndDirected(t *testing.T) { + for _, name := range Names() { + found, ok := For(name) + require.True(t, ok, name) + assert.NotZero(t, found.ServerPort, "%s needs a port, for its filter and its direction", name) + assert.Contains(t, []string{"tcp", "udp"}, found.Transport, "%s", name) + assert.NotEmpty(t, found.DefaultFilter(), "%s", name) + assert.NotNil(t, found.ByteOrder, "%s needs a byte order; EtherNet/IP proves it matters", name) + } +} diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/common/common.go b/plc4go/tools/plc4xpcapanalyzer/internal/common/common.go index d917b96dc..0bf18dfe3 100644 --- a/plc4go/tools/plc4xpcapanalyzer/internal/common/common.go +++ b/plc4go/tools/plc4xpcapanalyzer/internal/common/common.go @@ -24,8 +24,8 @@ import ( "net" "time" + "github.com/apache/plc4x/plc4go/spi/errors" "github.com/gopacket/gopacket" - "github.com/pkg/errors" ) type PacketInformation struct { @@ -34,6 +34,15 @@ type PacketInformation struct { Description string SrcIp net.IP DstIp net.IP + // SrcPort and DstPort are the transport ports, zero when the packet has no transport layer. + // + // They are here because they answer a question nothing else can answer cheaply: which end + // of this conversation is the device. A packet arriving at a protocol's registered port is + // a request and one leaving it is a response, which is how Wireshark decides and which + // needs nothing from the user. The alternative is being told the client's address, and a + // user who forgets sees half a capture reported as parse failures. + SrcPort int + DstPort int } func (p PacketInformation) String() string { diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/extractor/extractor.go b/plc4go/tools/plc4xpcapanalyzer/internal/extractor/extractor.go index 3a6f2a2be..c68562ade 100644 --- a/plc4go/tools/plc4xpcapanalyzer/internal/extractor/extractor.go +++ b/plc4go/tools/plc4xpcapanalyzer/internal/extractor/extractor.go @@ -24,37 +24,124 @@ import ( "fmt" "io" "net" + "os" - "github.com/apache/plc4x/plc4go-extras/tools/plc4xpcapanalyzer/config" - "github.com/apache/plc4x/plc4go-extras/tools/plc4xpcapanalyzer/internal/common" - "github.com/apache/plc4x/plc4go-extras/tools/plc4xpcapanalyzer/internal/pcaphandler" - - "github.com/fatih/color" + "github.com/apache/plc4x/plc4go/spi/errors" "github.com/gopacket/gopacket/layers" - "github.com/k0kubun/go-ansi" - "github.com/pkg/errors" "github.com/rs/zerolog/log" - "github.com/schollz/progressbar/v3" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/progress" + "github.com/apache/plc4x-extras/plc4go/tools/internal/tui" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/common" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/pcaphandler" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol" ) -func Extract(pcapFile, protocolType string) error { - return ExtractWithOutput(context.TODO(), pcapFile, protocolType, ansi.NewAnsiStdout(), ansi.NewAnsiStderr()) +// Options carries the collaborators one extraction run needs: where its output goes, who is +// told how far it has got, and what it styles its direction markers with. +// +// It is a struct rather than more parameters on ExtractWithOutput because that function's +// signature is part of the surface the pcap tool's tests pin. +type Options struct { + // Stdout receives the extracted payloads. This is routinely a pipe or a file. + Stdout io.Writer + // Stderr receives the direction markers and may carry a progress bar. + Stderr io.Writer + // Progress receives progress. Leaving it nil means "decide from the CLI configuration"; + // a terminal UI passes progress.NewChannel. + Progress progress.Reporter + // Theme styles the direction markers and the default progress bar. Leaving it nil resolves + // one per writer, so that a redirected stream gets no escape sequences. + Theme *tui.Theme +} + +// themeFor resolves the theme to style writes to w with. +// +// The colour is switched off when w is not a terminal. That is deliberately the behaviour of +// the fatih/color writers this replaces: extract's payload output is routinely piped into a +// file or another tool, and escape sequences in it corrupt the transcript. +func (o Options) themeFor(w io.Writer) tui.Theme { + if o.Theme != nil { + return *o.Theme + } + themeOptions := tui.OptionsFromEnv(true) + themeOptions.NoColor = themeOptions.NoColor || !progress.IsTerminal(w) + return tui.NewTheme(themeOptions) +} + +// reporter resolves the reporter this run should use. +func (o Options) reporter() progress.Reporter { + if o.Progress != nil { + return o.Progress + } + // ForWriter, not NewCLI: the bar and the zerolog stream share this descriptor, so a bar + // drawn into a redirected stderr would interleave with the log and corrupt both. + return progress.ForWriter(o.Stderr, config.RootConfigInstance.HideProgressBar, o.themeFor(o.Stderr)) +} + +// Extract dumps the application payloads of a capture. +// +// The context is honoured: the loop checks it between packets and stops cleanly. It used to be +// context.TODO here, which made the check inside the loop unreachable -- the abort was +// implemented and could never be asked for. +// +// os.Stdout and os.Stderr, rather than the go-ansi wrappers that used to be here: those +// wrappers hid the file descriptor, so nothing downstream could tell whether it was writing +// to a terminal, which is how the bar came to be drawn over the log in the first place. +func Extract(ctx context.Context, pcapFile, protocolType string) error { + return ExtractWithOutput(ctx, pcapFile, protocolType, os.Stdout, os.Stderr) } func ExtractWithOutput(ctx context.Context, pcapFile, protocolType string, stdout, stderr io.Writer) error { + return ExtractWithOptions(ctx, pcapFile, protocolType, Options{Stdout: stdout, Stderr: stderr}) +} + +// ExtractWithOptions is the full entry point; the others are conveniences over it. +func ExtractWithOptions(ctx context.Context, pcapFile, protocolType string, options Options) error { + stdout := options.Stdout + if stdout == nil { + stdout = io.Discard + } + stderr := options.Stderr + if stderr == nil { + stderr = io.Discard + } + reporter := options.reporter() + // Done from a defer as well: every early exit from the loop below has to release the line + // the bar is holding. + defer reporter.Done() + var printPayload = func(packetInformation common.PacketInformation, item []byte) { _, _ = fmt.Fprintf(stdout, "%x\n", item) } - switch protocolType { - case "bacnet": + proto, err := protocol.Resolve(protocolType) + if err != nil { + return err + } + switch proto.Name { + case protocol.BacnetIP.Name: // nothing special as this is byte based - case "c-bus": + case protocol.CBus.Name: // c-bus is string based so we consume the string and print it clientIp := net.ParseIP(config.ExtractConfigInstance.Client) - serverResponseWriter := color.New(color.FgRed) - serverResponseIndicatorWriter := color.New(color.FgHiRed) - clientRequestWriter := color.New(color.FgGreen) - clientRequestIndicatorWriter := color.New(color.FgHiGreen) + payloadTheme := options.themeFor(stdout) + markerTheme := options.themeFor(stderr) + // Red for what the PCI said, green for what the client asked - the same coding the + // fatih/color writers used, now resolved through the theme so that it adapts to the + // background, honours NO_COLOR and degrades to bold on a colourless terminal. + serverResponseStyle := payloadTheme.Err + clientRequestStyle := payloadTheme.Ok + serverResponseMarkerStyle := markerTheme.Err + clientRequestMarkerStyle := markerTheme.Ok + // Bold only where styling is wanted at all. A colourless theme must emit NO escape + // sequence, not merely no colour: this stream is routinely redirected to a file, and a + // stray "\x1b[1m" corrupts the transcript just as surely as a colour code would. That + // was the behaviour of the fatih/color writers this replaces. + if !markerTheme.IsNoColor() { + serverResponseMarkerStyle = serverResponseMarkerStyle.Bold(true) + clientRequestMarkerStyle = clientRequestMarkerStyle.Bold(true) + } printPayload = func(packetInformation common.PacketInformation, payload []byte) { payloadString := "" suffix := "" @@ -70,16 +157,21 @@ func ExtractWithOutput(ctx context.Context, pcapFile, protocolType string, stdou unquotedPayload := quotedPayload[1 : len(quotedPayload)-1] payloadString = unquotedPayload } + // The arrow comes from the glyph set rather than being written as "<--" here, so + // that a terminal without Unicode gets the ASCII arrow of the same display width + // instead of mojibake. if isResponse := packetInformation.DstIp.Equal(clientIp); isResponse { if config.ExtractConfigInstance.ShowDirectionalIndicators { - _, _ = serverResponseIndicatorWriter.Fprintf(stderr, "%s(<--pci)", extraInformation) + marker := fmt.Sprintf("%s(%spci)", extraInformation, markerTheme.Glyphs.Inbound) + _, _ = fmt.Fprint(stderr, serverResponseMarkerStyle.Render(marker)) } - _, _ = serverResponseWriter.Fprintf(stdout, "%s%s", payloadString, suffix) + _, _ = fmt.Fprint(stdout, serverResponseStyle.Render(payloadString)+suffix) } else { if config.ExtractConfigInstance.ShowDirectionalIndicators { - _, _ = clientRequestIndicatorWriter.Fprintf(stderr, "%s(-->pci)", extraInformation) + marker := fmt.Sprintf("%s(%spci)", extraInformation, markerTheme.Glyphs.Outbound) + _, _ = fmt.Fprint(stderr, clientRequestMarkerStyle.Render(marker)) } - _, _ = clientRequestWriter.Fprintf(stdout, "%s%s", payloadString, suffix) + _, _ = fmt.Fprint(stdout, clientRequestStyle.Render(payloadString)+suffix) } } } @@ -98,19 +190,9 @@ func ExtractWithOutput(ctx context.Context, pcapFile, protocolType string, stdou defer handle.Close() log.Debug().Interface("handle", handle).Int("numberOfPackage", numberOfPackage).Msg("got handle") source := pcaphandler.GetPacketSource(handle) - bar := progressbar.NewOptions(numberOfPackage, progressbar.OptionSetWriter(ansi.NewAnsiStderr()), - progressbar.OptionSetVisibility(!config.RootConfigInstance.HideProgressBar), - progressbar.OptionEnableColorCodes(true), - progressbar.OptionShowBytes(false), - progressbar.OptionSetWidth(15), - progressbar.OptionSetDescription("[cyan][1/3][reset] Analyzing packages..."), - progressbar.OptionSetTheme(progressbar.Theme{ - Saucer: "[green]=[reset]", - SaucerHead: "[green]>[reset]", - SaucerPadding: " ", - BarStart: "[", - BarEnd: "]", - })) + // "Extracting", not the "Analyzing packages..." this bar was copied from: the description + // is now a parameter, so it may as well say what is actually running. + reporter.Start(numberOfPackage, "Extracting packages") currentPackageNum := uint(0) parseFails := 0 serializeFails := 0 @@ -140,9 +222,7 @@ func ExtractWithOutput(ctx context.Context, pcapFile, protocolType string, stdou log.Debug().Msg("Done reading packages. (nil returned)") break } - if err := bar.Add(1); err != nil { - log.Warn().Err(err).Msg("Error updating progressBar") - } + reporter.Advance(1) packetTimestamp := packet.Metadata().Timestamp realPacketNumber := timestampToIndexMap[packetTimestamp] description := fmt.Sprintf("No.[%d] timestamp: %v, %s", realPacketNumber, packetTimestamp, pcapFile) diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/extractor/extractor_test.go b/plc4go/tools/plc4xpcapanalyzer/internal/extractor/extractor_test.go new file mode 100644 index 000000000..3126cfff0 --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/internal/extractor/extractor_test.go @@ -0,0 +1,231 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package extractor_test + +import ( + "bytes" + "io" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/tui" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/extractor" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol" +) + +// The extractor had no tests at all, and its colour handling, direction markers and writer +// wiring were all rewritten when the progress reporter replaced the fatih/color writers. These +// pin the behaviour that rewrite had to preserve. + +// countingReporter records what the extractor tells it. +type countingReporter struct { + mu sync.Mutex + total int + starts int + advanced int + dones int +} + +func (c *countingReporter) Start(total int, _ string) { + c.mu.Lock() + defer c.mu.Unlock() + c.starts++ + c.total = total +} + +func (c *countingReporter) Advance(delta int) { + c.mu.Lock() + defer c.mu.Unlock() + c.advanced += delta +} + +func (c *countingReporter) SetDescription(string) {} + +func (c *countingReporter) Done() { + c.mu.Lock() + defer c.mu.Unlock() + c.dones++ +} + +func (c *countingReporter) read() (total, starts, advanced, dones int) { + c.mu.Lock() + defer c.mu.Unlock() + return c.total, c.starts, c.advanced, c.dones +} + +// runExtract extracts a fixture, restoring the package-level configuration afterwards. +func runExtract(t *testing.T, pcapFile, protocolName string, theme *tui.Theme, configure func()) (stdout, stderr string, reporter *countingReporter, err error) { + t.Helper() + + savedPcap := config.PcapConfigInstance + savedExtract := config.ExtractConfigInstance + savedRoot := config.RootConfigInstance + savedLogger := log.Logger + savedLevel := zerolog.GlobalLevel() + t.Cleanup(func() { + config.PcapConfigInstance = savedPcap + config.ExtractConfigInstance = savedExtract + config.RootConfigInstance = savedRoot + log.Logger = savedLogger + zerolog.SetGlobalLevel(savedLevel) + }) + // The extractor logs at info on every packet; a test run is not the place for it. + log.Logger = zerolog.New(io.Discard) + zerolog.SetGlobalLevel(zerolog.Disabled) + config.RootConfigInstance.HideProgressBar = true + // The analysis loop treats a zero limit as "stop after the first packet". Nothing gives it + // a sane default: the CLI only avoids that because its flag default is math.MaxUint. A test + // driving the package directly has to say so itself. + config.PcapConfigInstance.PackageNumberLimit = ^uint(0) + // Payload output is gated behind verbosity: extractor.go:234 requires Verbosity > 1 before + // a payload is printed at all, and extractor.go:135 requires > 2 for the extra annotation. + // A test that leaves it at zero silently exercises an extractor that prints nothing. + config.ExtractConfigInstance.Verbosity = 2 + if configure != nil { + configure() + } + + var out, errOut bytes.Buffer + reporter = &countingReporter{} + err = extractor.ExtractWithOptions(t.Context(), pcapFile, protocolName, extractor.Options{ + Stdout: &out, + Stderr: &errOut, + Progress: reporter, + Theme: theme, + }) + return out.String(), errOut.String(), reporter, err +} + +func cbusFixture(t *testing.T) (path string, packets int) { + t.Helper() + path = filepath.Join(t.TempDir(), "cbus.pcap") + session := pcapfixture.CBusSession() + require.NoError(t, pcapfixture.WriteCBus(path, session)) + return path, len(session) +} + +// TestExtractWritesPayloadsToTheWriterItIsGiven is the core of the rewrite: the extractor used +// to build its own writers bound to the process's stdout and stderr, ignoring the ones it was +// handed. +func TestExtractWritesPayloadsToTheWriterItIsGiven(t *testing.T) { + pcapFile, _ := cbusFixture(t) + noColour := tui.NewTheme(tui.Options{Dark: true, NoColor: true}) + + stdout, _, _, err := runExtract(t, pcapFile, protocol.CBus.Name, &noColour, func() { + config.ExtractConfigInstance.Client = pcapfixture.ClientIP + }) + require.NoError(t, err) + + // Assert on real payload content, not merely on the stream being non-empty: the extractor + // emits a trailing newline even when it extracts nothing, so "not empty" would pass + // against an extractor that had stopped working entirely. + assert.Contains(t, stdout, "~~~", "the reset request from the fixture should appear in the output") + assert.Greater(t, len(strings.TrimSpace(stdout)), 10, "the payload stream should carry real content") +} + +// TestExtractDrivesTheProgressReporter is the wiring guard: deleting the Start or Advance call +// must fail a test. +func TestExtractDrivesTheProgressReporter(t *testing.T) { + pcapFile, packets := cbusFixture(t) + noColour := tui.NewTheme(tui.Options{Dark: true, NoColor: true}) + + _, _, reporter, err := runExtract(t, pcapFile, protocol.CBus.Name, &noColour, func() { + config.ExtractConfigInstance.Client = pcapfixture.ClientIP + }) + require.NoError(t, err) + + total, starts, advanced, dones := reporter.read() + assert.Positive(t, starts, "the extractor must announce the work it is about to do") + assert.Equal(t, packets, total, "Start must carry the packet count") + assert.Equal(t, packets, advanced, "every packet must advance the reporter once") + assert.Positive(t, dones, "the extractor must finish the bar rather than abandon it") +} + +// TestExtractEmitsNoEscapeSequencesWithAColourlessTheme pins the property the fatih/color +// writers provided: extract's output is routinely piped into a file, and escape sequences in +// it corrupt the transcript. +func TestExtractEmitsNoEscapeSequencesWithAColourlessTheme(t *testing.T) { + pcapFile, _ := cbusFixture(t) + noColour := tui.NewTheme(tui.Options{Dark: true, NoColor: true}) + + stdout, stderr, _, err := runExtract(t, pcapFile, protocol.CBus.Name, &noColour, func() { + config.ExtractConfigInstance.Client = pcapfixture.ClientIP + config.ExtractConfigInstance.ShowDirectionalIndicators = true + }) + require.NoError(t, err) + assert.NotContains(t, stdout, "\x1b[", "a colourless theme must not write escape sequences into the payload stream") + assert.NotContains(t, stderr, "\x1b[", "nor into the marker stream") +} + +// TestExtractDirectionalIndicatorsAreOptional covers the flag that gates the markers, which +// moved from a fatih/color writer to a themed one. +func TestExtractDirectionalIndicatorsAreOptional(t *testing.T) { + pcapFile, _ := cbusFixture(t) + noColour := tui.NewTheme(tui.Options{Dark: true, NoColor: true}) + + _, withMarkers, _, err := runExtract(t, pcapFile, protocol.CBus.Name, &noColour, func() { + config.ExtractConfigInstance.Client = pcapfixture.ClientIP + config.ExtractConfigInstance.ShowDirectionalIndicators = true + }) + require.NoError(t, err) + + _, withoutMarkers, _, err := runExtract(t, pcapFile, protocol.CBus.Name, &noColour, func() { + config.ExtractConfigInstance.Client = pcapfixture.ClientIP + config.ExtractConfigInstance.ShowDirectionalIndicators = false + }) + require.NoError(t, err) + + assert.NotEqual(t, withMarkers, withoutMarkers, + "the directional-indicator flag must actually change what is written") + assert.Greater(t, len(withMarkers), len(withoutMarkers), + "enabling the markers should add output, not replace it") +} + +// TestExtractRejectsAnUnknownProtocolBeforeOpeningTheCapture matches the analyser's behaviour, +// so a typo reads as a typo rather than as a capture problem. +func TestExtractRejectsAnUnknownProtocolBeforeOpeningTheCapture(t *testing.T) { + _, _, _, err := runExtract(t, filepath.Join(t.TempDir(), "absent.pcap"), "modbus", nil, nil) + require.Error(t, err) + assert.Contains(t, strings.ToLower(err.Error()), "unsupported protocol") +} + +// TestExtractAcceptsBothBacnetSpellings is the extractor's half of the protocol-alias fix: it +// used to match only "bacnet", disagreeing with the analyser, so "bacnetip" was rejected here. +func TestExtractAcceptsBothBacnetSpellings(t *testing.T) { + pcapFile := filepath.Join(t.TempDir(), "bacnet.pcap") + require.NoError(t, pcapfixture.WriteBacnet(pcapFile, pcapfixture.BacnetSession())) + noColour := tui.NewTheme(tui.Options{Dark: true, NoColor: true}) + + for _, name := range []string{"bacnet", "bacnetip"} { + t.Run(name, func(t *testing.T) { + _, _, _, err := runExtract(t, pcapFile, name, &noColour, nil) + assert.NoError(t, err, "%q must be extractable", name) + }) + } +} diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/finding/finding.go b/plc4go/tools/plc4xpcapanalyzer/internal/finding/finding.go new file mode 100644 index 000000000..620ea5bc0 --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/internal/finding/finding.go @@ -0,0 +1,242 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +// Package finding is what an analysed packet turned out to be, and the one place that decides. +// +// It exists because there were two answers to that question. internal/analyzer classified a +// packet inline and reported the result only by logging it; the terminal interface walked the +// capture again and classified it a second time, because bytes cannot be recovered from a log +// line. Two implementations of the same taxonomy drift, and this one did: the interface counted +// a skipped packet as normal traffic while the documentation described it as a failure, and +// nothing could have caught that because there was no shared definition to disagree with. +// +// Everything that produces a verdict now goes through Classify and Compare, and everything that +// counts them goes through Counters. A carrier may add fields of its own -- the interface adds +// the arrival offset, the direction and the parsed tree, which only a screen needs -- but the +// facts a finding consists of, and the rules for reaching them, live here. +package finding + +import ( + "fmt" + "strings" + + "github.com/apache/plc4x/plc4go/spi" + "github.com/pkg/errors" + + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/common" +) + +// Verdict is what an analysed packet turned out to be. +type Verdict int + +const ( + // VerdictOK is a packet that parsed, re-serialized and compared equal. + VerdictOK Verdict = iota + // VerdictBytesDiffer is the finding this tool exists to produce: the codec parsed the + // packet and then wrote back something else. + VerdictBytesDiffer + // VerdictParseFail is a payload the codec could not read. + VerdictParseFail + // VerdictSerializeFail is a message the codec could read but not write. + VerdictSerializeFail + // VerdictSkipped is a payload the protocol itself says is not a whole message: a split + // transmission, an empty packet or an echo. Not a defect, so not a finding. + VerdictSkipped + // VerdictFiltered is a packet the protocol mapping removed. + VerdictFiltered + // VerdictNoPayload is a packet with no application layer at all. + VerdictNoPayload +) + +// String is the label the packets table shows in its verdict column. The labels are short +// because that column is the first to lose width on a narrow terminal. +func (v Verdict) String() string { + switch v { + case VerdictOK: + return "ok" + case VerdictBytesDiffer: + return "bytes" + case VerdictParseFail: + return "parse" + case VerdictSerializeFail: + return "serial" + case VerdictSkipped: + return "skip" + case VerdictFiltered: + return "filter" + case VerdictNoPayload: + return "empty" + default: + return "?" + } +} + +// IsIssue reports whether this verdict is a defect: something the codec got wrong, as opposed +// to traffic there was nothing to check in. +// +// A skipped, filtered or payload-less packet is normal traffic, not a defect, and counting it +// as one would bury the three real findings among two hundred uninteresting ones. It is also +// what decides whether a packet appears in the findings tab, and whether it is reported as a +// failure or a skip to anything reading a report. +func (v Verdict) IsIssue() bool { + switch v { + case VerdictBytesDiffer, VerdictParseFail, VerdictSerializeFail: + return true + default: + return false + } +} + +// Reportable reports whether the packet was one the codec was actually asked about. A filtered +// packet or one with no application layer never reached a codec, so a report that counted it +// would be describing the capture rather than the codec. +func (v Verdict) Reportable() bool { + return v != VerdictFiltered && v != VerdictNoPayload +} + +// Finding is what one analysed packet turned out to be. +type Finding struct { + // Number is the packet's position in the unfiltered capture, which is the number a user + // comparing against Wireshark expects to see. + Number int + // Protocol is the canonical protocol name it was analysed as. + Protocol string + // Summary is the message type the codec parsed it into, empty when it did not parse. + Summary string + // Verdict is the outcome. + Verdict Verdict + // Reason carries the error or the explanation behind a non-OK verdict. + Reason string + // Original is the captured application payload. + Original []byte + // Reserialized is what the codec wrote back, nil when it never got that far. + Reserialized []byte + // DiffOffset is the offset of the first differing byte, or -1 when the two agree or when + // there was nothing to compare. + DiffOffset int +} + +// Classify turns a parse error into a verdict and a reason. +// +// The distinction it draws is the one that matters most in a report: a payload the protocol +// itself says is not a whole message is a skip, and a payload the codec was handed and could +// not read is a defect. Getting that wrong in either direction makes a report useless -- as +// noise in one direction, and as a false clean bill of health in the other. +func Classify(err error) (Verdict, string) { + switch { + case err == nil: + return VerdictOK, "" + case errors.Is(err, common.ErrUnterminatedPackage): + return VerdictSkipped, "unterminated" + case errors.Is(err, common.ErrEmptyPackage): + return VerdictSkipped, "empty" + case errors.Is(err, common.ErrEcho): + return VerdictSkipped, "echo" + default: + return VerdictParseFail, err.Error() + } +} + +// FirstDifference returns the offset of the first byte at which a and b disagree, treating a +// prefix as differing at the point where the shorter one ends. It returns -1 when the two are +// identical. +// +// This is the number the detail pane points its caret at and the offset a report names, and it +// is the single most useful piece of information the tool produces, so it is a named, tested +// function rather than an expression buried in a loop. +func FirstDifference(a, b []byte) int { + limit := min(len(a), len(b)) + for i := range limit { + if a[i] != b[i] { + return i + } + } + if len(a) != len(b) { + return limit + } + return -1 +} + +// DifferingBytes counts how many byte positions disagree, counting the tail of the longer slice +// as differing. It is what a report and a detail pane both say out loud: "3 bytes differ". +func DifferingBytes(a, b []byte) int { + limit := min(len(a), len(b)) + count := max(len(a), len(b)) - limit + for i := range limit { + if a[i] != b[i] { + count++ + } + } + return count +} + +// MessageName is the short name of a parsed message, which is what a finding's Summary holds +// and what both a packet table and a report identify a message by. +// +// It is here rather than beside either caller because it is part of what a finding says: two +// implementations would eventually disagree about whether the name keeps its package +// qualifier, and a report and a screen naming the same message differently is a bug in both. +func MessageName(message spi.Message) string { + name := fmt.Sprintf("%T", message) + if index := strings.LastIndex(name, "."); index >= 0 { + name = name[index+1:] + } + return strings.TrimPrefix(strings.TrimPrefix(name, "*"), "_") +} + +// Counters are the totals a run reports. +type Counters struct { + Walked int + Parsed int + ParseFail int + SerializeFail int + CompareFail int + Skipped int + TotalInCapture int +} + +// Count folds one verdict into the totals. +// +// One rule for all of them, because the alternative was two: the analyzer incremented three +// integers inline and the interface ran a switch of its own, and "parsed" in particular is +// easy to disagree about -- a packet that parsed and then failed to serialize did parse, and +// has to be counted as both. +func (c *Counters) Count(verdict Verdict) { + c.Walked++ + switch verdict { + case VerdictOK: + c.Parsed++ + case VerdictParseFail: + c.ParseFail++ + case VerdictSerializeFail: + c.Parsed++ + c.SerializeFail++ + case VerdictBytesDiffer: + c.Parsed++ + c.CompareFail++ + default: + // Skipped, filtered and no-payload alike. Every walked packet lands in exactly one + // category, so the totals add up and a report that shows them cannot imply that some + // packets went unaccounted for. + c.Skipped++ + } +} + +// Issues is how many defects the run found. +func (c Counters) Issues() int { return c.ParseFail + c.SerializeFail + c.CompareFail } diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/finding/finding_test.go b/plc4go/tools/plc4xpcapanalyzer/internal/finding/finding_test.go new file mode 100644 index 000000000..576bfbe0c --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/internal/finding/finding_test.go @@ -0,0 +1,162 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package finding + +import ( + "testing" + + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/common" +) + +// These rules had two implementations before this package existed, and they disagreed. The +// tests are therefore about the rules themselves rather than about either caller. + +// TestClassifyDrawsTheLineBetweenADefectAndATruce is the distinction that matters most: a +// payload the protocol says is not a whole message is a skip, and one the codec was handed and +// could not read is a defect. Wrong in one direction a report is noise; wrong in the other it is +// a false clean bill of health. +func TestClassifyDrawsTheLineBetweenADefectAndATruce(t *testing.T) { + for name, test := range map[string]struct { + err error + verdict Verdict + reason string + }{ + "parsed": {nil, VerdictOK, ""}, + "unterminated": {common.ErrUnterminatedPackage, VerdictSkipped, "unterminated"}, + "empty": {common.ErrEmptyPackage, VerdictSkipped, "empty"}, + "echo": {common.ErrEcho, VerdictSkipped, "echo"}, + "a real error": {errors.New("no command type could be found"), VerdictParseFail, "no command type could be found"}, + } { + verdict, reason := Classify(test.err) + assert.Equal(t, test.verdict, verdict, name) + assert.Equal(t, test.reason, reason, name) + } + + // Wrapped, because that is how these arrive from a codec. + verdict, reason := Classify(errors.Wrap(common.ErrEcho, "while reading packet 10")) + assert.Equal(t, VerdictSkipped, verdict, "a wrapped skip is still a skip") + assert.Equal(t, "echo", reason) +} + +// TestOnlyADefectIsAnIssue guards the taxonomy the findings tab and every report filter on. +func TestOnlyADefectIsAnIssue(t *testing.T) { + for verdict, want := range map[Verdict]bool{ + VerdictBytesDiffer: true, + VerdictParseFail: true, + VerdictSerializeFail: true, + VerdictOK: false, + VerdictSkipped: false, + VerdictFiltered: false, + VerdictNoPayload: false, + } { + assert.Equal(t, want, verdict.IsIssue(), verdict.String()) + } +} + +// TestOnlyWhatReachedACodecIsReportable is why a filtered packet is absent from a JUnit report: +// counting it would describe the capture rather than the codec, and inflate a total meant to +// say how much of plc4x was exercised. +func TestOnlyWhatReachedACodecIsReportable(t *testing.T) { + for verdict, want := range map[Verdict]bool{ + VerdictOK: true, + VerdictBytesDiffer: true, + VerdictParseFail: true, + VerdictSerializeFail: true, + VerdictSkipped: true, + VerdictFiltered: false, + VerdictNoPayload: false, + } { + assert.Equal(t, want, verdict.Reportable(), verdict.String()) + } +} + +// TestEveryVerdictHasADistinctLabel matters because the label is what a verdict column and a +// JUnit failure type both say. Two verdicts sharing a label would be indistinguishable in both. +func TestEveryVerdictHasADistinctLabel(t *testing.T) { + all := []Verdict{ + VerdictOK, VerdictBytesDiffer, VerdictParseFail, VerdictSerializeFail, + VerdictSkipped, VerdictFiltered, VerdictNoPayload, + } + seen := map[string]Verdict{} + for _, verdict := range all { + label := verdict.String() + assert.NotEmpty(t, label) + assert.NotEqual(t, "?", label, "a known verdict must not fall through to the unknown label") + if other, clash := seen[label]; clash { + t.Fatalf("%v and %v share the label %q", verdict, other, label) + } + seen[label] = verdict + } + assert.Equal(t, "?", Verdict(99).String(), "an unknown verdict is visibly unknown") +} + +// TestCountingIsOneRule is the arithmetic both paths now share. "Parsed" is the easy one to get +// wrong: a packet that parsed and then failed to serialize did parse, and has to count as both. +func TestCountingIsOneRule(t *testing.T) { + counters := Counters{} + for _, verdict := range []Verdict{ + VerdictOK, VerdictOK, VerdictParseFail, VerdictSerializeFail, + VerdictBytesDiffer, VerdictSkipped, VerdictFiltered, VerdictNoPayload, + } { + counters.Count(verdict) + } + + assert.Equal(t, 8, counters.Walked, "everything counted is walked") + assert.Equal(t, 4, counters.Parsed, "two clean, one that failed to serialize, one that differed") + assert.Equal(t, 1, counters.ParseFail) + assert.Equal(t, 1, counters.SerializeFail) + assert.Equal(t, 1, counters.CompareFail) + assert.Equal(t, 3, counters.Skipped, "a skip, a filtered packet and one with no payload") + assert.Equal(t, 3, counters.Issues()) + + // Every walked packet lands in exactly one category, so a report showing the totals cannot + // imply that some packets went unaccounted for. + assert.Equal(t, counters.Walked, + counters.Parsed-counters.SerializeFail-counters.CompareFail+ + counters.ParseFail+counters.SerializeFail+counters.CompareFail+counters.Skipped, + "the categories have to add up to the packets walked") +} + +// TestFirstDifferenceAndDifferingBytesAgree covers the number the detail pane points its caret +// at and the number a report names. +func TestFirstDifferenceAndDifferingBytesAgree(t *testing.T) { + for name, test := range map[string]struct { + a, b []byte + difference int + differing int + }{ + "identical": {[]byte("abc"), []byte("abc"), -1, 0}, + "both empty": {nil, nil, -1, 0}, + "middle": {[]byte("abcdef"), []byte("abcXef"), 3, 1}, + "first byte": {[]byte("abc"), []byte("Xbc"), 0, 1}, + "truncated": {[]byte("abcdef"), []byte("abc"), 3, 3}, + "longer": {[]byte("abc"), []byte("abcdef"), 3, 3}, + "one is empty": {[]byte("abc"), nil, 0, 3}, + } { + assert.Equal(t, test.difference, FirstDifference(test.a, test.b), "%s: first difference", name) + assert.Equal(t, test.differing, DifferingBytes(test.a, test.b), "%s: differing bytes", name) + + // The two have to agree about whether there is a difference at all. + assert.Equal(t, FirstDifference(test.a, test.b) < 0, DifferingBytes(test.a, test.b) == 0, name) + } +} diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture/pcapfixture.go b/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture/pcapfixture.go new file mode 100644 index 000000000..767bfa697 --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture/pcapfixture.go @@ -0,0 +1,306 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +// Package pcapfixture builds small, deterministic pcap captures containing real protocol +// traffic. +// +// It has two consumers. Tests use it so the analyzers can be exercised against genuine +// protocol bytes without shipping a binary capture or needing a device, and demo mode uses it +// so the tool has something real to analyze when the user has no capture of their own. +// +// The payloads in this package are not invented. Each one was taken from plc4x's own protocol +// tests, or is a well-known BACnet/IP service request, and each was verified to parse and then +// re-serialize to byte-identical output through the real plc4x codec. That property is what +// makes them useful: the analyzer's whole job is a parse, re-serialize and byte-compare loop, +// so a fixture that round-trips exercises the success path, and the deliberately broken +// payloads below exercise the failure counters. +// +// Captures are written with the pure-Go pcapgo writer rather than the libpcap binding, so +// building a fixture needs no cgo even though reading one back through the analyzer does. +package pcapfixture + +import ( + "encoding/hex" + "net" + "os" + "time" + + "github.com/apache/plc4x/plc4go/spi/errors" + "github.com/gopacket/gopacket" + "github.com/gopacket/gopacket/layers" + "github.com/gopacket/gopacket/pcapgo" +) + +// The synthetic endpoints. The c-bus analyzer decides whether a packet is a request or a +// response by comparing its source address against the configured client address, so a +// fixture and the configuration that reads it have to agree on these. +const ( + // ClientIP is the address that originates requests. + ClientIP = "192.168.178.101" + // ServerIP is the address that originates responses. + ServerIP = "192.168.178.10" + + // CBusPort is the TCP port C-Bus runs on. + CBusPort = 10001 + // BacnetPort is the UDP port BACnet/IP runs on. + BacnetPort = 47808 + // The registered ports of the other protocols, which are also the ports their default + // filters select. A fixture has to be written on the port the analyzer will look for. + ModbusPort = 502 + S7Port = 102 + EipPort = 44818 + KnxPort = 3671 + AdsPort = 48898 + AbEthPort = 2222 + SlmpPort = 5007 + // FirmataPort is not registered: Firmata is a serial protocol, so it only reaches a capture + // through a gateway. This is the port the fixtures use and the default filter looks for; a + // real tunnel needs the user's own --filter. + FirmataPort = 3030 + Iec104Port = 2404 + OpcuaPort = 4840 + UmasPort = 502 + ClientPort = 40000 +) + +// Transport is the transport a protocol runs over. +type Transport int + +const ( + // TCP is a stream protocol: the client connects from ClientPort to the server's port. + TCP Transport = iota + // UDP is a datagram protocol, where both ends use the protocol's own port. + UDP +) + +// Wire is where a protocol lives, which is all a capture needs to know about it. +type Wire struct { + Transport Transport + Port int +} + +// BaseTimestamp is the capture time of the first packet. It is fixed, not time.Now(), so that +// a generated capture is byte-identical from run to run and golden files stay stable. +var BaseTimestamp = time.Unix(1700000000, 0).UTC() + +// PacketInterval is added to BaseTimestamp for each successive packet. The analyzer indexes +// packets by timestamp, so two packets must never share one. +const PacketInterval = time.Millisecond + +// Direction says which endpoint sent a packet. +type Direction int + +const ( + // FromClient is a request: ClientIP -> ServerIP. + FromClient Direction = iota + // FromServer is a response: ServerIP -> ClientIP. + FromServer +) + +// Packet is one application payload and the direction it travelled. +type Packet struct { + // Payload is the application-layer bytes, which is what the analyzer parses. + Payload []byte + // Direction selects the source and destination addresses. + Direction Direction + // Comment describes the packet for humans reading a fixture definition. + Comment string +} + +// CBusSession returns a C-Bus exchange whose every packet parses and re-serializes to +// identical bytes. Requests and responses are interleaved the way a real session runs. +func CBusSession() []Packet { + return []Packet{ + {Payload: []byte("~~~\r"), Direction: FromClient, Comment: "reset request"}, + {Payload: []byte("322100AD\r\n"), Direction: FromServer, Comment: "reply"}, + {Payload: []byte("@A32100FF\r"), Direction: FromClient, Comment: "direct command"}, + {Payload: []byte("3230009E\r\n"), Direction: FromServer, Comment: "reply"}, + {Payload: []byte("@A3300079\r"), Direction: FromClient, Comment: "direct command"}, + {Payload: []byte("0531AC0079042F0401430316000011\r\n"), Direction: FromServer, Comment: "monitored SAL"}, + {Payload: []byte("86FD0201078900434C495053414C20C2\r\n"), Direction: FromServer, Comment: "identify reply"}, + {Payload: []byte("g.890050435F434E49454422\r\n"), Direction: FromServer, Comment: "MMI"}, + } +} + +// CBusSessionWithFailures returns the C-Bus session plus payloads the codec will not accept. +// It exists so tests can assert the analyzer's parse-failure counter, which is otherwise only +// reachable with a real broken capture. +func CBusSessionWithFailures() []Packet { + return append(CBusSession(), + // Two payloads the codec will not accept, and deliberately not in the same way: the + // first is a parse failure, which is a defect and therefore a finding, and the second + // the codec rejects as not being a whole message at all, which is a skip and not a + // finding. A fixture with only one of the two cannot exercise the difference, and the + // difference is what the verdict column is for. + Packet{Payload: []byte("AFFE!!!\r"), Direction: FromClient, Comment: "parse failure"}, + Packet{Payload: []byte("@A62120\r"), Direction: FromClient, Comment: "skipped: not a whole message"}, + ) +} + +// BacnetSession returns a BACnet/IP exchange whose every packet parses and re-serializes to +// identical bytes. +// +// Note on filtering: the CLI's default BACnet filter is "udp port 47808 and udp[4:2] > 29", +// which drops packets whose UDP length is 29 bytes or less. A bare Who-Is is 12 bytes of BVLC +// and is therefore dropped by that filter. The longer payloads here survive it, and a test +// that wants the short ones has to disable filtering. +func BacnetSession() []Packet { + return []Packet{ + {Payload: mustHex("810b00180120ffff00ff1000c40200271f2201e09100210f"), Direction: FromServer, Comment: "I-Am broadcast"}, + {Payload: mustHex("810a001101040275010c0c020000271955"), Direction: FromClient, Comment: "ReadProperty request"}, + {Payload: mustHex("810b000c0120ffff00ff1008"), Direction: FromClient, Comment: "Who-Is broadcast (short: needs no filter)"}, + {Payload: mustHex("810a000801001008"), Direction: FromClient, Comment: "Who-Is unicast (short: needs no filter)"}, + } +} + +// WriteCBus writes packets to path as a C-Bus capture: Ethernet/IPv4/TCP on CBusPort. +func WriteCBus(path string, packets []Packet) error { + return write(path, packets, func(p Packet, ip *layers.IPv4) (gopacket.SerializableLayer, error) { + src, dst := layers.TCPPort(40000), layers.TCPPort(CBusPort) + if p.Direction == FromServer { + src, dst = dst, src + } + tcp := &layers.TCP{SrcPort: src, DstPort: dst, Seq: 1, Window: 4096, PSH: true, ACK: true} + if err := tcp.SetNetworkLayerForChecksum(ip); err != nil { + return nil, errors.Wrap(err, "error preparing tcp checksum") + } + return tcp, nil + }) +} + +// WriteBacnet writes packets to path as a BACnet/IP capture: Ethernet/IPv4/UDP on BacnetPort. +func WriteBacnet(path string, packets []Packet) error { + return write(path, packets, func(p Packet, ip *layers.IPv4) (gopacket.SerializableLayer, error) { + udp := &layers.UDP{SrcPort: layers.UDPPort(BacnetPort), DstPort: layers.UDPPort(BacnetPort)} + if err := udp.SetNetworkLayerForChecksum(ip); err != nil { + return nil, errors.Wrap(err, "error preparing udp checksum") + } + return udp, nil + }) +} + +// Write writes packets to path as a capture of a protocol living on the given wire. +// +// One writer for every protocol, because the transport and the port are the only things that +// differ between them -- and getting the port wrong makes a fixture the analyzer's own default +// filter throws away, which reads as "the codec parsed nothing" rather than as a broken test. +func Write(path string, wire Wire, packets []Packet) error { + return write(path, packets, func(p Packet, ip *layers.IPv4) (gopacket.SerializableLayer, error) { + if wire.Transport == UDP { + udp := &layers.UDP{ + SrcPort: layers.UDPPort(wire.Port), + DstPort: layers.UDPPort(wire.Port), + } + if err := udp.SetNetworkLayerForChecksum(ip); err != nil { + return nil, errors.Wrap(err, "error preparing udp checksum") + } + return udp, nil + } + src, dst := layers.TCPPort(ClientPort), layers.TCPPort(wire.Port) + if p.Direction == FromServer { + src, dst = dst, src + } + tcp := &layers.TCP{SrcPort: src, DstPort: dst, Seq: 1, Window: 4096, PSH: true, ACK: true} + if err := tcp.SetNetworkLayerForChecksum(ip); err != nil { + return nil, errors.Wrap(err, "error preparing tcp checksum") + } + return tcp, nil + }) +} + +// write assembles each packet and writes the capture. transportFor supplies the transport +// layer, which is the only part that differs between the protocols. +func write(path string, packets []Packet, transportFor func(Packet, *layers.IPv4) (gopacket.SerializableLayer, error)) error { + if len(packets) == 0 { + return errors.New("refusing to write a capture with no packets") + } + file, err := os.Create(path) + if err != nil { + return errors.Wrapf(err, "error creating capture %s", path) + } + defer func() { _ = file.Close() }() + + writer := pcapgo.NewWriter(file) + // snaplen 65536 matches what a default libpcap capture would record. + if err := writer.WriteFileHeader(65536, layers.LinkTypeEthernet); err != nil { + return errors.Wrap(err, "error writing pcap file header") + } + + for i, packet := range packets { + raw, err := serialize(packet, transportFor) + if err != nil { + return errors.Wrapf(err, "error serializing packet %d (%s)", i+1, packet.Comment) + } + info := gopacket.CaptureInfo{ + Timestamp: BaseTimestamp.Add(time.Duration(i) * PacketInterval), + CaptureLength: len(raw), + Length: len(raw), + } + if err := writer.WritePacket(info, raw); err != nil { + return errors.Wrapf(err, "error writing packet %d", i+1) + } + } + return nil +} + +// serialize builds the full Ethernet frame for one packet. +func serialize(packet Packet, transportFor func(Packet, *layers.IPv4) (gopacket.SerializableLayer, error)) ([]byte, error) { + clientMAC := net.HardwareAddr{0x00, 0x11, 0x22, 0x33, 0x44, 0x55} + serverMAC := net.HardwareAddr{0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb} + srcMAC, dstMAC := clientMAC, serverMAC + srcIP, dstIP := net.ParseIP(ClientIP), net.ParseIP(ServerIP) + if packet.Direction == FromServer { + srcMAC, dstMAC = serverMAC, clientMAC + srcIP, dstIP = dstIP, srcIP + } + + ethernet := &layers.Ethernet{SrcMAC: srcMAC, DstMAC: dstMAC, EthernetType: layers.EthernetTypeIPv4} + ip := &layers.IPv4{ + Version: 4, + IHL: 5, + TTL: 64, + SrcIP: srcIP, + DstIP: dstIP, + Protocol: layers.IPProtocolTCP, + } + transport, err := transportFor(packet, ip) + if err != nil { + return nil, err + } + if _, isUDP := transport.(*layers.UDP); isUDP { + ip.Protocol = layers.IPProtocolUDP + } + + buffer := gopacket.NewSerializeBuffer() + options := gopacket.SerializeOptions{FixLengths: true, ComputeChecksums: true} + if err := gopacket.SerializeLayers(buffer, options, ethernet, ip, transport, gopacket.Payload(packet.Payload)); err != nil { + return nil, errors.Wrap(err, "error serializing layers") + } + return buffer.Bytes(), nil +} + +// mustHex decodes a hex payload defined in this file. The inputs are compile-time constants +// that are covered by this package's tests, so a decode failure is a programming error. +func mustHex(s string) []byte { + decoded, err := hex.DecodeString(s) + if err != nil { + panic("pcapfixture: invalid hex literal " + s + ": " + err.Error()) + } + return decoded +} diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture/pcapfixture_test.go b/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture/pcapfixture_test.go new file mode 100644 index 000000000..0ebee439a --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture/pcapfixture_test.go @@ -0,0 +1,216 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package pcapfixture + +import ( + "bytes" + "context" + "os" + "path/filepath" + "testing" + + bacnetModel "github.com/apache/plc4x/plc4go/protocols/bacnetip/readwrite/model" + cbusModel "github.com/apache/plc4x/plc4go/protocols/cbus/readwrite/model" + "github.com/gopacket/gopacket" + "github.com/gopacket/gopacket/layers" + "github.com/gopacket/gopacket/pcapgo" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// readBack reads a written capture with the pure-Go reader, so this test needs no cgo, and +// returns the application payloads in capture order. +func readBack(t *testing.T, path string) []gopacket.Packet { + t.Helper() + file, err := os.Open(path) + require.NoError(t, err) + t.Cleanup(func() { _ = file.Close() }) + + reader, err := pcapgo.NewReader(file) + require.NoError(t, err) + + var out []gopacket.Packet + for packet := range gopacket.NewPacketSource(reader, layers.LinkTypeEthernet).Packets() { + out = append(out, packet) + } + return out +} + +// TestCBusFixturePayloadsSurviveTheCapture is the guard that the capture writer does not +// corrupt or reorder what the analyzer will later parse. +func TestCBusFixturePayloadsSurviveTheCapture(t *testing.T) { + path := filepath.Join(t.TempDir(), "cbus.pcap") + want := CBusSession() + require.NoError(t, WriteCBus(path, want)) + + got := readBack(t, path) + require.Len(t, got, len(want), "every fixture packet must reach the capture") + + for i, packet := range got { + app := packet.ApplicationLayer() + require.NotNil(t, app, "packet %d (%s) must carry an application layer", i+1, want[i].Comment) + assert.Equal(t, want[i].Payload, app.Payload(), + "packet %d (%s) payload must round-trip through the capture", i+1, want[i].Comment) + } +} + +func TestBacnetFixturePayloadsSurviveTheCapture(t *testing.T) { + path := filepath.Join(t.TempDir(), "bacnet.pcap") + want := BacnetSession() + require.NoError(t, WriteBacnet(path, want)) + + got := readBack(t, path) + require.Len(t, got, len(want)) + for i, packet := range got { + app := packet.ApplicationLayer() + require.NotNil(t, app, "packet %d (%s)", i+1, want[i].Comment) + assert.Equal(t, want[i].Payload, app.Payload(), "packet %d (%s)", i+1, want[i].Comment) + } +} + +// TestCBusFixtureDirectionsMapToAddresses pins the property the c-bus analyzer depends on to +// tell a request from a response: it compares the packet's source IP against the configured +// client address. +func TestCBusFixtureDirectionsMapToAddresses(t *testing.T) { + path := filepath.Join(t.TempDir(), "cbus.pcap") + want := CBusSession() + require.NoError(t, WriteCBus(path, want)) + + for i, packet := range readBack(t, path) { + ip, ok := packet.NetworkLayer().(*layers.IPv4) + require.True(t, ok, "packet %d must have an IPv4 layer", i+1) + if want[i].Direction == FromClient { + assert.Equal(t, ClientIP, ip.SrcIP.String(), "packet %d is a request", i+1) + assert.Equal(t, ServerIP, ip.DstIP.String(), "packet %d is a request", i+1) + } else { + assert.Equal(t, ServerIP, ip.SrcIP.String(), "packet %d is a response", i+1) + assert.Equal(t, ClientIP, ip.DstIP.String(), "packet %d is a response", i+1) + } + } +} + +func TestCBusFixtureUsesTcpOnTheCBusPort(t *testing.T) { + path := filepath.Join(t.TempDir(), "cbus.pcap") + require.NoError(t, WriteCBus(path, CBusSession())) + for i, packet := range readBack(t, path) { + tcp, ok := packet.TransportLayer().(*layers.TCP) + require.True(t, ok, "packet %d must be TCP", i+1) + assert.True(t, tcp.SrcPort == CBusPort || tcp.DstPort == CBusPort, + "packet %d must involve the c-bus port, got %v->%v", i+1, tcp.SrcPort, tcp.DstPort) + } +} + +func TestBacnetFixtureUsesUdpOnTheBacnetPort(t *testing.T) { + path := filepath.Join(t.TempDir(), "bacnet.pcap") + require.NoError(t, WriteBacnet(path, BacnetSession())) + for i, packet := range readBack(t, path) { + udp, ok := packet.TransportLayer().(*layers.UDP) + require.True(t, ok, "packet %d must be UDP", i+1) + assert.EqualValues(t, BacnetPort, uint16(udp.DstPort), "packet %d", i+1) + } +} + +// TestCBusSessionPayloadsRoundTripThroughTheRealCodec is the heart of the fixture's value: it +// drives plc4x's actual C-Bus codec with the fixture bytes and asserts every payload parses +// and re-serializes byte-identically. If a plc4x upgrade changes C-Bus encoding, this fails. +func TestCBusSessionPayloadsRoundTripThroughTheRealCodec(t *testing.T) { + for i, packet := range CBusSession() { + t.Run(packet.Comment, func(t *testing.T) { + isResponse := packet.Direction == FromServer + // Responses carry a checksum, which is what the srchk option selects. + options := cbusModel.NewCBusOptions(false, false, false, false, false, false, false, false, isResponse) + parsed, err := cbusModel.CBusMessageParse[cbusModel.CBusMessage]( + context.Background(), packet.Payload, isResponse, cbusModel.NewRequestContext(false), options) + require.NoError(t, err, "fixture packet %d (%q) must parse", i+1, packet.Payload) + + serialized, err := parsed.Serialize() + require.NoError(t, err) + assert.True(t, bytes.Equal(packet.Payload, serialized), + "fixture packet %d must re-serialize identically\n got: %q\nwant: %q", i+1, serialized, packet.Payload) + }) + } +} + +// TestBacnetSessionPayloadsRoundTripThroughTheRealCodec is the BACnet counterpart. +func TestBacnetSessionPayloadsRoundTripThroughTheRealCodec(t *testing.T) { + for i, packet := range BacnetSession() { + t.Run(packet.Comment, func(t *testing.T) { + parsed, err := bacnetModel.BVLCParse[bacnetModel.BVLC](context.Background(), packet.Payload) + require.NoError(t, err, "fixture packet %d (%x) must parse", i+1, packet.Payload) + + serialized, err := parsed.Serialize() + require.NoError(t, err) + assert.True(t, bytes.Equal(packet.Payload, serialized), + "fixture packet %d must re-serialize identically\n got: %x\nwant: %x", i+1, serialized, packet.Payload) + }) + } +} + +// TestCBusFailurePayloadsDoNotParse pins the negatives. The analyzer's parse-failure counter is +// only meaningful if these really do fail, so if a plc4x upgrade starts accepting them the +// failure-path tests built on this fixture would silently stop testing anything. +func TestCBusFailurePayloadsDoNotParse(t *testing.T) { + session := CBusSession() + withFailures := CBusSessionWithFailures() + require.Greater(t, len(withFailures), len(session), "the failure fixture must add packets") + + for _, packet := range withFailures[len(session):] { + t.Run(string(packet.Payload), func(t *testing.T) { + options := cbusModel.NewCBusOptions(false, false, false, false, false, false, false, false, false) + _, err := cbusModel.CBusMessageParse[cbusModel.CBusMessage]( + context.Background(), packet.Payload, false, cbusModel.NewRequestContext(false), options) + assert.Error(t, err, "payload %q is a fixture negative and must not parse", packet.Payload) + }) + } +} + +// TestCapturesAreByteIdenticalAcrossRuns is what makes golden-file testing viable: the writer +// must not embed a wall-clock timestamp. +func TestCapturesAreByteIdenticalAcrossRuns(t *testing.T) { + dir := t.TempDir() + first, second := filepath.Join(dir, "a.pcap"), filepath.Join(dir, "b.pcap") + require.NoError(t, WriteCBus(first, CBusSession())) + require.NoError(t, WriteCBus(second, CBusSession())) + + a, err := os.ReadFile(first) + require.NoError(t, err) + b, err := os.ReadFile(second) + require.NoError(t, err) + assert.True(t, bytes.Equal(a, b), "two writes of the same fixture must produce identical bytes") +} + +// TestEveryPacketHasADistinctTimestamp matters because the analyzer indexes packets by +// timestamp; duplicates would collapse its packet numbering. +func TestEveryPacketHasADistinctTimestamp(t *testing.T) { + path := filepath.Join(t.TempDir(), "cbus.pcap") + require.NoError(t, WriteCBus(path, CBusSession())) + + seen := map[int64]bool{} + for i, packet := range readBack(t, path) { + ts := packet.Metadata().Timestamp.UnixNano() + assert.False(t, seen[ts], "packet %d reuses timestamp %d", i+1, ts) + seen[ts] = true + } +} + +func TestWriteRejectsAnEmptyCapture(t *testing.T) { + err := WriteCBus(filepath.Join(t.TempDir(), "empty.pcap"), nil) + assert.Error(t, err, "an empty capture is a programming mistake, not a valid fixture") +} diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture/vectors.go b/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture/vectors.go new file mode 100644 index 000000000..cf859a1ab --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture/vectors.go @@ -0,0 +1,247 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package pcapfixture + +// Reference vectors, one session per protocol. +// +// Every payload here is taken from the protocol's own ParserSerializerTestsuite in the Apache +// PLC4X repository -- protocols//src/test/resources/protocols// -- which +// plc4x asserts round-trips: parse the raw bytes, serialize the result, get the same bytes. +// That is exactly the property the analyzer measures, so a capture built from them is a capture +// the analyzer must report as entirely clean. A failure here means either the analyzer's +// adapter is wrong or plc4x has regressed, and those are the only two things worth knowing. +// +// The direction of each packet is the "response" parser argument its test case declares, not a +// guess from its name: several of these protocols encode the two directions differently, and +// the analyzer derives the direction from the client address, so a packet placed on the wrong +// side would be read the wrong way round. +// +// Generated from those suites rather than hand-copied, then committed, so the tests stay +// hermetic and do not depend on a plc4x checkout sitting beside this one. + +// AbEthWire is where ab-eth traffic is written for a fixture. +var AbEthWire = Wire{Transport: TCP, Port: AbEthPort} + +// AbEthSession is the ab-eth reference vectors, 4 packets that all round-trip. +func AbEthSession() []Packet { + return []Packet{ + {Payload: mustHex("01010000000000000000000000040005000000000000000000000000"), Direction: FromClient, Comment: "Connection Request"}, + {Payload: mustHex("02010000000003320000000000040005000000000000000000000000"), Direction: FromClient, Comment: "Connection Response"}, + {Payload: mustHex("0107000e000003320000000040000000000000000000000000000000080500000f000401a21800640000"), Direction: FromClient, Comment: "Protected Typed Logical Read Request"}, + {Payload: mustHex("02070020000003320000000040000000000000000000000000000000000508004f000401910101000900040405001f02010003000404050000024000"), Direction: FromClient, Comment: "Protected Typed Logical Read Response"}, + } +} + +// AdsWire is where ads traffic is written for a fixture. +var AdsWire = Wire{Transport: TCP, Port: AdsPort} + +// AdsSession is the ads reference vectors, 8 packets that all round-trip. +func AdsSession() []Packet { + return []Packet{ + {Payload: mustHex("00002c000000c0a8171401015303c0a817c801015303020004000c000000000000000200000005f000000000801a01000000"), Direction: FromClient, Comment: "Ams-Single-Item-Read-Request"}, + {Payload: mustHex("000029000000c0a817c801015303c0a817140101530302000500090000000000000002000000000000000100000001"), Direction: FromClient, Comment: "Ams-Single-Item-Read-Response"}, + {Payload: mustHex("00004a000000c0a8171401015303c0a817cd0101feff090004002a000000000000000100000003f0000000000000040000001a0000006d61696e2e665f74726967446174656947656c6573656e2e4d00"), Direction: FromClient, Comment: "Ams-Resolve-Symbolic-Address-Request"}, + {Payload: mustHex("00002c000000c0a817cd0101feffc0a8171401015303090005000c000000000000000100000000000000040000000100801b"), Direction: FromClient, Comment: "Ams-Resolve-Symbolic-Address-Response"}, + {Payload: mustHex("00002c000000c0a8171401015303c0a817cd0101feff020004000c000000000000000100000005f000000100801b04000000"), Direction: FromClient, Comment: "Ams-Read-Symbolic-Address-Request"}, + {Payload: mustHex("000029000000c0a817cd0101feffc0a817140101530302000500090000000000000001000000000000000100000001"), Direction: FromClient, Comment: "Ams-Read-Symbolic-Address-Response"}, + {Payload: mustHex("000030000000c0a8171401015303c0a817cd0101feff0300040010000000000000000100000006f0000000000000040000000100801b"), Direction: FromClient, Comment: "Ams-Release-Symbolic-Address-Handle-Request"}, + {Payload: mustHex("000024000000c0a817cd0101feffc0a81714010153030300050004000000000000000100000000000000"), Direction: FromClient, Comment: "Ams-Release-Symbolic-Address-Handle-Response"}, + } +} + +// EipWire is where eip traffic is written for a fixture. +var EipWire = Wire{Transport: TCP, Port: EipPort} + +// EipSession is the eip reference vectors, 27 packets that all round-trip. +func EipSession() []Packet { + return []Packet{ + {Payload: mustHex("70003800c9070440000000005765277265000000000000000000000000000200a10004005942feffb10024000100520d91124b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b910370707000010000000000"), Direction: FromClient, Comment: "CIP Connected Read Request"}, + {Payload: mustHex("6f0044001d5e074000000000000000000000000000000000000000000000020000000000b20034005b02200624010a0e02000020b4a500002bcb37132a0000000300000034122000a20f004201402000a20f0042a303010020022401"), Direction: FromClient, Comment: "CIP Connection Manager Open Large Forward Request"}, + {Payload: mustHex("6f002e001d5e074000000000000000000000000000000000000000000000020000000000b2001e00db0000005b6bfeffb4a500002bcb37132a00000034122000014020000000"), Direction: FromServer, Comment: "CIP Connection Manager Open Large Forward Response"}, + {Payload: mustHex("70002e001d5e0740000000005765277265000000000000000000000000000200a10004005b6bfeffb1001a0001005208910d5a5a5a5f5a5a5a5f415252415900010000000000"), Direction: FromClient, Comment: "CIP Connected Write Request"}, + {Payload: mustHex("700020001d5e0740000000000000000000000000000000000000000000000200a1000400b4a50000b1000c000100d2000000c40000000000"), Direction: FromServer, Comment: "CIP Connected Write Response"}, + {Payload: mustHex("700032001d5e0740000000006e6f000000000000000000000000000000000200a10004005b6bfeffb1001e0002004d09910d5a5a5a5f5a5a5a5f4152524159002801c400010001000000"), Direction: FromClient, Comment: "CIP Connected Write Request 4D"}, + {Payload: mustHex("700032001d5e0740000000006e6f000000000000000000000000000000000200a10004005b6bfeffb1001e0002004d09910d5a5a5a5f5a5a5a5f4152524159002801c400010001000000"), Direction: FromServer, Comment: "CIP Connected Write Response 4D"}, + {Payload: mustHex("6f0028001d5e074000000000000000000000000000000000000000000000020000000000b20018004e02200624010a0e2bcb37132a0000000300010020022401"), Direction: FromClient, Comment: "CIP Connection Manager Close Request"}, + {Payload: mustHex("6f001e00c907044000000000000000000000000000000000000000000000020000000000b2000e00ce000000902137132a0000000000"), Direction: FromServer, Comment: "CIP Connection Manager Close Response"}, + {Payload: mustHex("650004000000000000000000302e382e332020200000000001000000"), Direction: FromClient, Comment: "ENIP Register Session Request"}, + {Payload: mustHex("650004001d5e074000000000302e382e332020200000000001000000"), Direction: FromServer, Comment: "ENIP Register Session Response"}, + {Payload: mustHex("660000001d5e074000000000000000000000000000000000"), Direction: FromClient, Comment: "ENIP Un-Register Session Request"}, + {Payload: mustHex("6f002c002a01004000000000000000000000000000000000000000000000020000000000b2001c00520220062401059d0e004c0591084d79537472696e67010001000100"), Direction: FromClient, Comment: "CIP Unconnected Send Request"}, + {Payload: mustHex("6f0070002a01004000000000000000000000000000000000000000000000020000000000b2006000cc000000a002ce0f2b000000556d6d2c2049206e6f772073656520796f7520696e207468652077697265736861726b20636170747572650000000000000000000000000000000000000000000000000000000000000000000000000000000000"), Direction: FromServer, Comment: "CIP Unconnected Send Response"}, + {Payload: mustHex("6500040081926a3500000000504c4334582020200000000001000000"), Direction: FromServer, Comment: "CIP Register Response - Simulator"}, + {Payload: mustHex("700020005e5dccfd00000000504c433458202020000000000000000000000200a1000400c3a45cf2b1000c000100cc000000ca0000000000"), Direction: FromServer, Comment: "CIP Read Response 4C"}, + {Payload: mustHex("04000000ec9a149000000000000000000000000000000000"), Direction: FromClient, Comment: "EIP List Services Request"}, + {Payload: mustHex("04001900ec9a14900000000000000000000000000000000001000001130001002000436f6d6d756e69636174696f6e7300"), Direction: FromServer, Comment: "EIP List Services Response"}, + {Payload: mustHex("040019000000000000000000504c4334582020200000000001000001130001002000436f6d6d756e69636174696f6e7300"), Direction: FromServer, Comment: "EIP List Services Response 2"}, + {Payload: mustHex("6f00160045000040000000001400000030145b0300000000000000002000020000000000b2000600010220022401"), Direction: FromClient, Comment: "EIP Get Attribute List Request - Message Router"}, + {Payload: mustHex("6f00b60045000040000000001400000030145b0300000000000000000000020000000000b200a600810000004e003a03770066004300f6003700f500ac035f005d005e000003ab033703a503040348004203a4038b002f031204b603b203b303b003b10330034f004e00aa03a803a703a6036e037003320331032d031703b20049033503710072007803ac00b0002b03b100730067006b0068007d038d008c006d006a0038031a0369004500f20074006e008e0070006c0002006a036400a100f4000100c100c000060000010500"), Direction: FromServer, Comment: "EIP Get Attribute List Response - Message Router"}, + {Payload: mustHex("6f004c08b181852100000000504c43345820202000000000000000000000020000000000b2003c088100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"), Direction: FromServer, Comment: "EIP Get Attribute List Response - Message Router - Multiple Attributes - Bad Response Handling"}, + {Payload: mustHex("6f0014000100000000000000504c43345820202000000000000000000000020000000000b200040081000800"), Direction: FromServer, Comment: "EIP Get Attribute List Response - Message Router - Unknown Service Response Handling"}, + {Payload: mustHex("6f0014000100000000000000504c43345820202000000000000000000000020000000000b2000400d2000800"), Direction: FromServer, Comment: "CIP Read Response - Unknown Service Response Handling"}, + {Payload: mustHex("6f0018001d5e074000000000504c43345820202000000000000000000000020000000000b20008000e03200124013001"), Direction: FromClient, Comment: "CIP Get Attribute Single Request"}, + {Payload: mustHex("6f0016001d5e074000000000504c43345820202000000000000000000000020000000000b20006008e0000000100"), Direction: FromServer, Comment: "CIP Get Attribute Single Response"}, + {Payload: mustHex("6f0020001d5e074000000000504c43345820202000000000000000000000020000000000b2001000db0001010001341237132a0000000200"), Direction: FromServer, Comment: "CIP Connection Manager Forward Open rejected"}, + } +} + +// FirmataWire is where firmata traffic is written for a fixture. +var FirmataWire = Wire{Transport: TCP, Port: FirmataPort} + +// FirmataSession is the firmata reference vectors, 13 packets that all round-trip. +func FirmataSession() []Packet { + return []Packet{ + {Payload: mustHex("ff"), Direction: FromClient, Comment: "Firmata Reset"}, + {Payload: mustHex("f90205"), Direction: FromServer, Comment: "Firmata Report Version"}, + {Payload: mustHex("f07902055300740061006e0064006100720064004600690072006d006100740061002e0069006e006f00f7"), Direction: FromServer, Comment: "Firmata Report Version And Name"}, + {Payload: mustHex("f079f7"), Direction: FromClient, Comment: "FirmataMessageCommand->FirmataCommandSysex->SysexCommandReportFirmware (Request)"}, + {Payload: mustHex("f07902055300740061006e0064006100720064004600690072006d006100740061002e0069006e006f00f7"), Direction: FromServer, Comment: "FirmataMessageCommand->FirmataCommandSysex->SysexCommandReportFirmware (Response)"}, + {Payload: mustHex("f069f7"), Direction: FromClient, Comment: "FirmataMessageCommand->FirmataCommandSysex->SysexCommandAnalogMappingQuery (Request)"}, + {Payload: mustHex("f06bf7"), Direction: FromClient, Comment: "FirmataMessageCommand->FirmataCommandSysex->SysexCommandCapabilityQuery (Request)"}, + {Payload: mustHex("c001"), Direction: FromClient, Comment: "FirmataMessageSubscribeAnalogPinValue (Pin 0)"}, + {Payload: mustHex("d001"), Direction: FromClient, Comment: "FirmataMessageSubscribeDigitalPinValue (Pin 0)"}, + {Payload: mustHex("e05403"), Direction: FromServer, Comment: "FirmataMessageAnalogIO (Pin 0)"}, + {Payload: mustHex("900000"), Direction: FromServer, Comment: "FirmataMessageDigitalIO (Pin 0)"}, + {Payload: mustHex("f06d02f7"), Direction: FromClient, Comment: "FirmataMessageCommand->FirmataCommandSysex->SysexCommandPinStateQuery (Pin 2)"}, + {Payload: mustHex("f06e020100f7"), Direction: FromServer, Comment: "FirmataMessageCommand->FirmataCommandSysex->SysexCommandPinStateResponse"}, + } +} + +// KnxWire is where knxnet-ip traffic is written for a fixture. +var KnxWire = Wire{Transport: UDP, Port: KnxPort} + +// KnxSession is the knxnet-ip reference vectors, 16 packets that all round-trip. +func KnxSession() []Packet { + return []Packet{ + {Payload: mustHex("06100201000e0801c0a82ac8d6b4"), Direction: FromClient, Comment: "Search Request"}, + {Payload: mustHex("06100202004c0801c0a82a0b0e5736010200ffff000000082d409852e000170c000ab327553647697261204b4e582f49502d5363686e6974747374656c6c6500000000000802020103010401"), Direction: FromClient, Comment: "Search Response"}, + {Payload: mustHex("06100203000e0801000000000000"), Direction: FromClient, Comment: "Description Request"}, + {Payload: mustHex("06100204004436010200ffff000000082d409852e000170c000ab327553647697261204b4e582f49502d5363686e6974747374656c6c6500000000000802020103010401"), Direction: FromClient, Comment: "Description Response"}, + {Payload: mustHex("06100205001a0801c0a82ac8d6b40801c0a82ac8d6b404040200"), Direction: FromClient, Comment: "Connect Request"}, + {Payload: mustHex("06100206001402000801c0a82a0b0e570404fffe"), Direction: FromClient, Comment: "Connect Response"}, + {Payload: mustHex("06100207001002000801c0a82ac8d6b4"), Direction: FromClient, Comment: "Connection State Request"}, + {Payload: mustHex("0610020800080200"), Direction: FromClient, Comment: "Connection State Response"}, + {Payload: mustHex("06100310001104670000fc000001531001"), Direction: FromClient, Comment: "Device Configuration Request"}, + {Payload: mustHex("06100311000a04670000"), Direction: FromClient, Comment: "Device Configuration Ack"}, + {Payload: mustHex("06100209001001000801c0a82a0b0e57"), Direction: FromClient, Comment: "Disconnect Request"}, + {Payload: mustHex("0610020a00086600"), Direction: FromClient, Comment: "Disconnect Response"}, + {Payload: mustHex("061004200015040200002900bce0220a120c010081"), Direction: FromClient, Comment: "Tunneling Request"}, + {Payload: mustHex("06100420001c046b00002b0703010504024502bc360a1e0ce100810d"), Direction: FromClient, Comment: "Tunneling Request (Busmon)"}, + {Payload: mustHex("06100421000a046b0000"), Direction: FromClient, Comment: "Tunneling Response"}, + {Payload: mustHex("0610020500180801c0a82a46c4090801c0a82a46c40a0203"), Direction: FromClient, Comment: "Default"}, + } +} + +// ModbusWire is where modbus-tcp traffic is written for a fixture. +var ModbusWire = Wire{Transport: TCP, Port: ModbusPort} + +// ModbusSession is the modbus-tcp reference vectors, 6 packets that all round-trip. +func ModbusSession() []Packet { + return []Packet{ + {Payload: mustHex("000000000006ff0408d20002"), Direction: FromClient, Comment: "Read Input Registers Request"}, + {Payload: mustHex("7cfe000000c9ff04c600000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000004000000000000000000000000000001db000001d600004a380000000000000000000000000000000000000000000000000000000000006461696d006e0000000000000000000000000000303100300000000000000000000000000000000000000000000000000000000000000000000000000000"), Direction: FromServer, Comment: "Read Input Registers Response"}, + {Payload: mustHex("000a0000001101140e060003270e000206000400000008"), Direction: FromClient, Comment: "Read Extended Registers Request Split File Record"}, + {Payload: mustHex("000a0000001b011418050600000000110600000000000000000000000000000000"), Direction: FromServer, Comment: "Read Extended Registers Response Split File Record"}, + {Payload: mustHex("000a0000000c011509060002000000010008"), Direction: FromClient, Comment: "Write Extended Registers Request File Record"}, + {Payload: mustHex("000a00000015011512060001270f00010000060002000000010000"), Direction: FromClient, Comment: "Write Extended Registers Request Split File Record"}, + } +} + +// ModbusAsciiWire is where modbus-ascii traffic is written for a fixture. +var ModbusAsciiWire = Wire{Transport: TCP, Port: ModbusPort} + +// ModbusAsciiSession is the modbus-ascii reference vectors, 2 packets that all round-trip. +func ModbusAsciiSession() []Packet { + return []Packet{ + {Payload: mustHex("01030000000af2"), Direction: FromClient, Comment: "Read Holding Registers Request"}, + {Payload: mustHex("0103140000000000000000000000000000000000000000e8"), Direction: FromServer, Comment: "Read Holding Registers Response"}, + } +} + +// ModbusRtuWire is where modbus-rtu traffic is written for a fixture. +var ModbusRtuWire = Wire{Transport: TCP, Port: ModbusPort} + +// ModbusRtuSession is the modbus-rtu reference vectors, 2 packets that all round-trip. +func ModbusRtuSession() []Packet { + return []Packet{ + {Payload: mustHex("01030000000ac5cd"), Direction: FromClient, Comment: "Read Holding Registers Request"}, + {Payload: mustHex("0103140000000000000000000000000000000000000000a367"), Direction: FromServer, Comment: "Read Holding Registers Response"}, + } +} + +// S7Wire is where s7 traffic is written for a fixture. +var S7Wire = Wire{Transport: TCP, Port: S7Port} + +// S7Session is the s7 reference vectors, 11 packets that all round-trip. +func S7Session() []Packet { + return []Packet{ + {Payload: mustHex("0300001611e00000000f00c2020100c1020311c0010a"), Direction: FromClient, Comment: "COTP Connection Request"}, + {Payload: mustHex("0300001611d0000f000b00c0010ac1020311c2020100"), Direction: FromClient, Comment: "COTP Connection Response"}, + {Payload: mustHex("0300001902f08132010000000000080000f0000008000803f0"), Direction: FromClient, Comment: "S7 Setup Communication Request"}, + {Payload: mustHex("0300001b02f080320300000000000800000000f0000003000300f0"), Direction: FromClient, Comment: "S7 Setup Communication Response"}, + {Payload: mustHex("0300002102f082320700000001000800080001120411440100ff09000400110000"), Direction: FromClient, Comment: "S7 Read PLC Type Request"}, + {Payload: mustHex("0300007d02f080320700000001000c0060000112081284010100000000ff09005c00110000001c0003000136455337203231322d31424433302d3058423020202000012020000636455337203231322d31424433302d3058423020202000012020000736455337203231322d31424433302d3058423020202056020002"), Direction: FromClient, Comment: "S7 Read PLC Type Response"}, + {Payload: mustHex("0300004302f08b32010000000b003200000404120a10010001000082000000120a10010001000082000000120a10010001000082000000120a10010001000082000000"), Direction: FromClient, Comment: "S7 Read Request"}, + {Payload: mustHex("0300002c02f08032030000000b0002001700000404ff0300010100ff0300010100ff0300010100ff03000101"), Direction: FromClient, Comment: "S7 Read Response"}, + {Payload: mustHex("0300001302f08032020000000a000000008500"), Direction: FromClient, Comment: "S7 Read Error Response"}, + {Payload: mustHex("0300005a02f08e32010000000e003200170504120a10010001000082000000120a10010001000082000001120a10010001000082000002120a10010001000082000003ff0300010100ff0300010100ff0300010100ff03000101"), Direction: FromClient, Comment: "S7 Write Request"}, + {Payload: mustHex("0300001902f08032030000000e0002000400000504ffffffff"), Direction: FromClient, Comment: "S7 Write Response"}, + } +} + +// SlmpWire is where slmp traffic is written for a fixture. +var SlmpWire = Wire{Transport: TCP, Port: SlmpPort} + +// SlmpSession is the slmp reference vectors, 5 packets that all round-trip. +func SlmpSession() []Packet { + return []Packet{ + {Payload: mustHex("500000ffff03000c000000010400005e0100a80200"), Direction: FromClient, Comment: "3E Batch Read Request - D350, 2 words"}, + {Payload: mustHex("d00000ffff030006000000ab560f17"), Direction: FromClient, Comment: "3E Batch Read Response - D350=0x56AB, D351=0x170F"}, + {Payload: mustHex("500000ffff030024000000030400000403000000a8000000c2640000902000009cdc0500a86001009d57040090"), Direction: FromClient, Comment: "3E Random Read Request - D0,T0,M100,X20 word + D1500,Y160,M1111 dword"}, + {Payload: mustHex("d00000ffff03001600000095190212302049484e4f544cafb9dec3b7bcddba"), Direction: FromClient, Comment: "3E Random Read Response - 4 word + 3 dword values"}, + {Payload: mustHex("500000ffff030026000000060400000203000000a80400000100b40800000000900200800000900200000100a00300"), Direction: FromClient, Comment: "3E Multi-block Read Request - 2 word blocks (D0-D3, W100-W107) + 3 bit blocks (M0-M31, M128-M159, B100-B12F)"}, + } +} + +// ModbusExceptionSession is a Modbus exchange whose responses are exceptions. +// +// This used to be the one fixture that deliberately did not round-trip. plc4x lost the function +// code of an exception response: Modbus flags an error by setting the top bit of the original +// function code -- 0x03 becomes 0x83, 0x08 becomes 0x88 -- and ModbusPDUError captured only the +// flag, so it wrote back 0x80 whatever went in, its own parser having read the function correctly +// and its own serializer having then discarded it. +// +// The cause was in the code generator rather than in the Go binding, which is why every language +// binding did it: the type switch in modbus.mspec matches ModbusPDUError on the error bit alone, +// leaving the generator with no constant for the function code. It now retains a discriminator +// value that no sub type pins, and these round-trip exactly. +// +// The payloads are real. The 0x88 exchange is from a public capture of 2004 shipped in +// gopacket's own testdata, where nine packets show it; the 0x83 one came from a live +// capture. plc4x's own Modbus test suites contained no exception response at all -- the error +// flag was false in every case across TCP, RTU and ASCII -- which is why nobody had noticed. +func ModbusExceptionSession() []Packet { + return []Packet{ + {Payload: mustHex("00000000000600010000000a"), Direction: FromClient, Comment: "read holding registers request"}, + {Payload: mustHex("0000000000030a880b"), Direction: FromServer, Comment: "exception response to function 0x08 (public capture)"}, + {Payload: mustHex("000100000006010300000002"), Direction: FromClient, Comment: "read holding registers request"}, + {Payload: mustHex("00010000000301830b"), Direction: FromServer, Comment: "exception response to function 0x03"}, + } +} diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/pcaphandler/pcaphandler.go b/plc4go/tools/plc4xpcapanalyzer/internal/pcaphandler/pcaphandler.go index fa3fa1f79..6a0664c30 100644 --- a/plc4go/tools/plc4xpcapanalyzer/internal/pcaphandler/pcaphandler.go +++ b/plc4go/tools/plc4xpcapanalyzer/internal/pcaphandler/pcaphandler.go @@ -22,9 +22,9 @@ package pcaphandler import ( "time" + "github.com/apache/plc4x/plc4go/spi/errors" "github.com/gopacket/gopacket" "github.com/gopacket/gopacket/pcap" - "github.com/pkg/errors" ) // GetPacketSource gets a packet source from a handle diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/protocol/protocol.go b/plc4go/tools/plc4xpcapanalyzer/internal/protocol/protocol.go new file mode 100644 index 000000000..aac3d742a --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/internal/protocol/protocol.go @@ -0,0 +1,162 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +// Package protocol is the single source of truth for the protocol names the pcap analyzer +// understands. +// +// It exists because the name accepted at the command line, the name the analyzer switched on +// and the name the extractor switched on used to disagree: the CLI accepted "bacnet", the +// analyzer only matched "bacnetip" and the extractor only matched "bacnet". Every route into +// the analyzer for BACnet therefore failed. Resolving names here, once, keeps the CLI, the +// analyzer, the extractor and the TUI from drifting apart again. +package protocol + +import ( + "slices" + "sort" + "strings" + + "github.com/apache/plc4x/plc4go/spi/errors" +) + +// Protocol is a protocol the analyzer can parse, serialize and byte-compare. +type Protocol struct { + // Name is the canonical name. It matches the plc4x driver protocol code, so it is also + // what a plc4x connection string would use. + Name string + // Aliases are additional names accepted from users, for convenience and for backwards + // compatibility with names earlier versions accepted. + Aliases []string + // Description is a short human-readable label used in help output. + Description string +} + +// String returns the canonical name, so a Protocol can be passed anywhere a name is logged. +func (p Protocol) String() string { return p.Name } + +// Matches reports whether name refers to this protocol, by canonical name or by alias. +// Comparison is case-insensitive and ignores surrounding whitespace. +func (p Protocol) Matches(name string) bool { + name = normalize(name) + if name == p.Name { + return true + } + return slices.Contains(p.Aliases, name) +} + +// The supported protocols. BACnet/IP is canonically "bacnetip", matching the plc4x driver +// protocol code, with "bacnet" accepted as an alias because the CLI has always advertised it. +var ( + BacnetIP = Protocol{ + Name: "bacnetip", + Aliases: []string{"bacnet", "bacnet-ip"}, + Description: "BACnet/IP", + } + CBus = Protocol{ + Name: "c-bus", + Aliases: []string{"cbus"}, + Description: "Clipsal C-Bus", + } + + // The rest, examined through internal/codec. The names are the plc4x driver codes, which + // is deliberately the same vocabulary the browser uses: a user who connected to a device + // with "modbus-tcp" should not have to learn a second spelling to analyse its traffic. + ModbusTcp = Protocol{Name: "modbus-tcp", Description: "Modbus TCP"} + ModbusRtu = Protocol{Name: "modbus-rtu", Description: "Modbus RTU (needs --filter: serial unless tunnelled)"} + ModbusAscii = Protocol{Name: "modbus-ascii", Description: "Modbus ASCII (needs --filter: serial unless tunnelled)"} + S7 = Protocol{Name: "s7", Description: "Siemens S7"} + Eip = Protocol{Name: "eip", Aliases: []string{"logix"}, Description: "EtherNet/IP"} + KnxNetIp = Protocol{Name: "knxnet-ip", Aliases: []string{"knxnetip"}, Description: "KNXNet/IP"} + Ads = Protocol{Name: "ads", Description: "Beckhoff TwinCAT ADS"} + AbEth = Protocol{Name: "ab-eth", Aliases: []string{"abeth"}, Description: "Allen Bradley ETH"} + Slmp = Protocol{Name: "slmp", Description: "SLMP (MELSEC)"} + Firmata = Protocol{Name: "firmata", Description: "Firmata (needs --filter: serial unless tunnelled)"} +) + +// all is the registry. Keep it ordered by canonical name so help output is stable. +var all = []Protocol{ + AbEth, Ads, BacnetIP, CBus, Eip, Firmata, KnxNetIp, + ModbusAscii, ModbusRtu, ModbusTcp, S7, Slmp, +} + +// All returns every supported protocol. +func All() []Protocol { + out := make([]Protocol, len(all)) + copy(out, all) + return out +} + +// Catalogue is the protocols as help text: one per line, canonical name, aliases and +// description. +// +// Generated rather than written, because a hand-written list is a list that goes stale. The +// help said "bacnetip or c-bus" and would have kept saying it after ten more were added. +func Catalogue() string { + var out strings.Builder + for _, p := range all { + name := p.Name + if len(p.Aliases) > 0 { + name += " (" + strings.Join(p.Aliases, ", ") + ")" + } + out.WriteString(" " + name) + if p.Description != "" { + out.WriteString(strings.Repeat(" ", max(1, 30-len(name))) + p.Description) + } + out.WriteString("\n") + } + return out.String() +} + +// Names returns the canonical names, sorted, for help and error messages. +func Names() []string { + names := make([]string, 0, len(all)) + for _, p := range all { + names = append(names, p.Name) + } + sort.Strings(names) + return names +} + +// Resolve maps a user-supplied name, canonical or alias, onto a supported protocol. +// The returned error names every accepted spelling, since that is what a user needs to +// recover from a typo. +func Resolve(name string) (Protocol, error) { + for _, p := range all { + if p.Matches(name) { + return p, nil + } + } + return Protocol{}, errors.Errorf("unsupported protocol %q, supported protocols are %s (accepted aliases: %s)", + name, strings.Join(Names(), ", "), strings.Join(aliases(), ", ")) +} + +// aliases returns every accepted alias, sorted, for error messages. +func aliases() []string { + var out []string + for _, p := range all { + out = append(out, p.Aliases...) + } + sort.Strings(out) + return out +} + +// normalize puts a user-supplied name into the form the registry compares against. +func normalize(name string) string { + return strings.ToLower(strings.TrimSpace(name)) +} diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/protocol/protocol_test.go b/plc4go/tools/plc4xpcapanalyzer/internal/protocol/protocol_test.go new file mode 100644 index 000000000..a04e436cc --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/internal/protocol/protocol_test.go @@ -0,0 +1,137 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package protocol + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestResolveAcceptsBacnetAlias is the regression test for the defect that made every BACnet +// route into the analyzer fail: the CLI accepted "bacnet" while the analyzer only ever matched +// "bacnetip", so the name never reached a branch that could handle it. +func TestResolveAcceptsBacnetAlias(t *testing.T) { + for _, name := range []string{"bacnet", "bacnetip"} { + t.Run(name, func(t *testing.T) { + got, err := Resolve(name) + require.NoError(t, err, "the CLI has always advertised %q, so it must resolve", name) + assert.Equal(t, BacnetIP.Name, got.Name, + "both spellings must collapse onto the one canonical name the analyzer switches on") + }) + } +} + +func TestResolveAcceptsCBusSpellings(t *testing.T) { + for _, name := range []string{"c-bus", "cbus"} { + t.Run(name, func(t *testing.T) { + got, err := Resolve(name) + require.NoError(t, err) + assert.Equal(t, CBus.Name, got.Name) + }) + } +} + +func TestResolveIsCaseAndSpaceInsensitive(t *testing.T) { + tests := map[string]Protocol{ + "BACnet": BacnetIP, + "BACNETIP": BacnetIP, + " bacnet ": BacnetIP, + "C-Bus": CBus, + "\tcbus\n": CBus, + } + for input, want := range tests { + t.Run(input, func(t *testing.T) { + got, err := Resolve(input) + require.NoError(t, err) + assert.Equal(t, want.Name, got.Name) + }) + } +} + +func TestResolveRejectsUnknownProtocolAndSaysWhatIsAccepted(t *testing.T) { + _, err := Resolve("modbus") + require.Error(t, err) + // The message has to be actionable: a user who typo'd needs to see the real options. + assert.Contains(t, err.Error(), "modbus", "the rejected input should be echoed back") + assert.Contains(t, err.Error(), "bacnetip") + assert.Contains(t, err.Error(), "c-bus") +} + +func TestResolveRejectsEmptyName(t *testing.T) { + _, err := Resolve("") + assert.Error(t, err, "an empty protocol must not silently resolve to the first entry") +} + +func TestNamesAreCanonicalAndSorted(t *testing.T) { + // Spelled out rather than derived, so that adding a protocol is a deliberate act with a + // visible diff: this list is what the help offers and what a user is allowed to type. + assert.Equal(t, []string{ + "ab-eth", "ads", "bacnetip", "c-bus", "eip", "firmata", "knxnet-ip", + "modbus-ascii", "modbus-rtu", "modbus-tcp", "s7", "slmp", + }, Names(), "help output depends on this being stable and canonical") + + // And sorted, whatever the list becomes. + assert.IsIncreasing(t, Names(), "the registry has to stay ordered by canonical name") +} + +func TestAllReturnsACopy(t *testing.T) { + first := All() + require.NotEmpty(t, first) + first[0] = Protocol{Name: "clobbered"} + assert.NotEqual(t, "clobbered", All()[0].Name, "All must not expose the package registry for mutation") +} + +// TestEveryProtocolResolvesByItsOwnName guards against adding a protocol to the registry with +// a name that Resolve cannot find. +func TestEveryProtocolResolvesByItsOwnName(t *testing.T) { + for _, p := range All() { + t.Run(p.Name, func(t *testing.T) { + got, err := Resolve(p.Name) + require.NoError(t, err) + assert.Equal(t, p.Name, got.Name) + for _, alias := range p.Aliases { + gotAlias, err := Resolve(alias) + require.NoError(t, err, "alias %q must resolve", alias) + assert.Equal(t, p.Name, gotAlias.Name) + } + }) + } +} + +// TestNoDuplicateNamesOrAliases catches a copy-paste mistake in the registry that would make +// Resolve's first-match-wins behaviour silently ambiguous. +func TestNoDuplicateNamesOrAliases(t *testing.T) { + seen := map[string]string{} + for _, p := range All() { + for _, spelling := range append([]string{p.Name}, p.Aliases...) { + if owner, dup := seen[spelling]; dup { + t.Errorf("spelling %q is claimed by both %q and %q", spelling, owner, p.Name) + } + seen[spelling] = p.Name + } + } +} + +func TestStringIsTheCanonicalName(t *testing.T) { + assert.Equal(t, "bacnetip", BacnetIP.String()) + assert.Equal(t, "c-bus", CBus.String()) +} diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/report/junit.go b/plc4go/tools/plc4xpcapanalyzer/internal/report/junit.go new file mode 100644 index 000000000..c557ae59b --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/internal/report/junit.go @@ -0,0 +1,234 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package report + +import ( + "encoding/hex" + "encoding/xml" + "strconv" + "time" + + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/finding" +) + +// The JUnit document. The element and attribute names are the format's, not ours, which is why +// they are spelled the way they are. + +type junitSuites struct { + XMLName xml.Name `xml:"testsuites"` + Name string `xml:"name,attr"` + Tests int `xml:"tests,attr"` + Failures int `xml:"failures,attr"` + Skipped int `xml:"skipped,attr"` + Time string `xml:"time,attr"` + Suites []junitSuite `xml:"testsuite"` +} + +type junitSuite struct { + Name string `xml:"name,attr"` + Tests int `xml:"tests,attr"` + Failures int `xml:"failures,attr"` + Skipped int `xml:"skipped,attr"` + Time string `xml:"time,attr"` + Timestamp string `xml:"timestamp,attr,omitempty"` + Properties []junitProperty `xml:"properties>property,omitempty"` + Cases []junitCase `xml:"testcase"` +} + +type junitProperty struct { + Name string `xml:"name,attr"` + Value string `xml:"value,attr"` +} + +type junitCase struct { + Name string `xml:"name,attr"` + ClassName string `xml:"classname,attr"` + Time string `xml:"time,attr"` + Failure *junitFailure `xml:"failure,omitempty"` + Skipped *junitSkipped `xml:"skipped,omitempty"` +} + +type junitFailure struct { + Type string `xml:"type,attr"` + Message string `xml:"message,attr"` + Detail string `xml:",chardata"` +} + +type junitSkipped struct { + Message string `xml:"message,attr"` +} + +// asJUnit turns a report into the document. +// +// One suite per capture rather than one per protocol, because a capture is the unit a person +// re-runs and a suite is the unit CI groups by. Filtered packets and packets with no +// application layer are left out: they never reached a codec, so counting them would describe +// the capture rather than the codec, and would inflate a "tests" total that is meant to say how +// much of plc4x was exercised. +func asJUnit(report Report) junitSuites { + suite := junitSuite{ + Name: report.suiteName(), + Time: seconds(report.Elapsed), + Timestamp: timestamp(report.Started), + Properties: []junitProperty{ + {Name: "protocol", Value: report.Protocol}, + {Name: "capture", Value: report.Capture}, + {Name: "packetsInCapture", Value: strconv.Itoa(report.Counters.TotalInCapture)}, + {Name: "packetsWalked", Value: strconv.Itoa(report.Counters.Walked)}, + {Name: "aborted", Value: strconv.FormatBool(report.Aborted)}, + }, + } + + for _, found := range report.Findings { + if !found.Verdict.Reportable() { + continue + } + testCase := junitCase{ + Name: caseName(found), + ClassName: className(report, found), + Time: "0", + } + switch { + case found.Verdict.IsIssue(): + testCase.Failure = &junitFailure{ + Type: found.Verdict.String(), + Message: sanitise(message(found)), + Detail: detail(found), + } + suite.Failures++ + case found.Verdict == finding.VerdictSkipped: + testCase.Skipped = &junitSkipped{Message: sanitise(found.Reason)} + suite.Skipped++ + } + suite.Cases = append(suite.Cases, testCase) + suite.Tests++ + } + + return junitSuites{ + Name: report.suiteName(), + Tests: suite.Tests, + Failures: suite.Failures, + Skipped: suite.Skipped, + Time: suite.Time, + Suites: []junitSuite{suite}, + } +} + +// caseName identifies the packet. The number is what a user comparing against Wireshark looks +// for, and the message name is what makes a failure recognisable in a list of a thousand. +func caseName(found finding.Finding) string { + name := "packet " + strconv.Itoa(found.Number) + if found.Summary != "" { + name += " " + found.Summary + } + return sanitise(name) +} + +// className is the group CI files the case under: the protocol, so that a run over a corpus of +// captures shows which protocol's codec is unhappy. +func className(report Report, found finding.Finding) string { + if found.Protocol != "" { + return found.Protocol + } + return report.Protocol +} + +// message is the one-line summary of a failure, which is what a CI list shows before anyone +// clicks into the detail. +func message(found finding.Finding) string { + switch found.Verdict { + case finding.VerdictParseFail: + return "could not be parsed: " + found.Reason + case finding.VerdictSerializeFail: + return "parsed but could not be re-serialized: " + found.Reason + case finding.VerdictBytesDiffer: + if found.DiffOffset >= 0 { + return "re-serialized to different bytes, first differing at offset " + + strconv.Itoa(found.DiffOffset) + } + return "re-serialized to different bytes" + default: + return found.Reason + } +} + +// seconds is a duration as JUnit wants it. +func seconds(d time.Duration) string { + return strconv.FormatFloat(d.Seconds(), 'f', 3, 64) +} + +// timestamp is a start time as JUnit wants it, and empty for a zero time rather than the year 1. +func timestamp(t time.Time) string { + if t.IsZero() { + return "" + } + return t.UTC().Format("2006-01-02T15:04:05") +} + +// asJSON is the report as a JSON document, with the payloads hex-encoded so that the file stays +// text and can be read by a person as well as a program. +func asJSON(report Report) jsonReport { + out := jsonReport{ + Capture: report.Capture, + Protocol: report.Protocol, + Elapsed: report.Elapsed.String(), + Aborted: report.Aborted, + Counters: report.Counters, + } + if !report.Started.IsZero() { + out.Started = report.Started.UTC().Format(time.RFC3339) + } + for _, found := range report.Findings { + out.Findings = append(out.Findings, jsonFinding{ + Number: found.Number, + Protocol: found.Protocol, + Summary: found.Summary, + Verdict: found.Verdict.String(), + Issue: found.Verdict.IsIssue(), + Reason: found.Reason, + Original: hex.EncodeToString(found.Original), + Reserialized: hex.EncodeToString(found.Reserialized), + DiffOffset: found.DiffOffset, + }) + } + return out +} + +type jsonReport struct { + Capture string `json:"capture"` + Protocol string `json:"protocol"` + Started string `json:"started,omitempty"` + Elapsed string `json:"elapsed"` + Aborted bool `json:"aborted"` + Counters finding.Counters `json:"counters"` + Findings []jsonFinding `json:"findings"` +} + +type jsonFinding struct { + Number int `json:"number"` + Protocol string `json:"protocol"` + Summary string `json:"summary,omitempty"` + Verdict string `json:"verdict"` + Issue bool `json:"issue"` + Reason string `json:"reason,omitempty"` + Original string `json:"original,omitempty"` + Reserialized string `json:"reserialized,omitempty"` + DiffOffset int `json:"diffOffset"` +} diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/report/report.go b/plc4go/tools/plc4xpcapanalyzer/internal/report/report.go new file mode 100644 index 000000000..800d0c50a --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/internal/report/report.go @@ -0,0 +1,205 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +// Package report writes what a run found in a form something other than a person can read. +// +// The analyzer is a regression harness for plc4x's codecs: run it over a corpus of captures and +// a codec that stops round-tripping a message shows up as a failure. That is only useful if the +// failure survives the run, and until now it did not -- a finding existed as a log line among +// info lines for every packet, and as a number in a summary, so you could learn that three +// packets failed but not which or why without grepping. +// +// JUnit XML is the default because it is what this repository already speaks: the Go tests are +// run through gotestsum --junitfile into target/surefire-reports, and the workflow renders that +// with dorny/test-reporter as java-junit. A capture becomes a suite and a packet becomes a test +// case, so a codec regression arrives in CI as a named failing test with the offending bytes +// attached, and is tracked across builds for free. +package report + +import ( + "encoding/hex" + "encoding/json" + "encoding/xml" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/pkg/errors" + + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/finding" +) + +// Report is one run, as something reading it needs to see it. +type Report struct { + // Capture is the path of the capture that was analysed. + Capture string + // Protocol is the canonical protocol name it was analysed as. + Protocol string + // Started is when the run began, and Elapsed how long it took. + Started time.Time + Elapsed time.Duration + // Counters are the totals. + Counters finding.Counters + // Findings is what each analysed packet turned out to be, in capture order. + Findings []finding.Finding + // Aborted says the run was interrupted, so the findings are a prefix of the capture rather + // than all of it. A report that did not say so would read as a clean bill of health for + // packets nobody looked at. + Aborted bool +} + +// Format is a report format. +type Format string + +const ( + // FormatJUnit is JUnit XML, which is what CI consumes. + FormatJUnit Format = "junit" + // FormatJSON is the same content as JSON, for anything that would rather not read XML. + FormatJSON Format = "json" +) + +// FormatFor picks a format from a path's extension. Anything but .json is JUnit XML, since that +// is the point of the feature and .xml is what a user will write. +func FormatFor(path string) Format { + if strings.EqualFold(filepath.Ext(path), ".json") { + return FormatJSON + } + return FormatJUnit +} + +// WriteFile writes a report to path, in the format its extension implies, creating the +// directory if it is missing. +// +// The directory is created because the natural place to write one is +// target/surefire-reports/.xml, and asking a user to create a directory before a +// flag will work is a papercut with no upside. +func WriteFile(path string, report Report) error { + if directory := filepath.Dir(path); directory != "" { + if err := os.MkdirAll(directory, 0o755); err != nil { + return errors.Wrapf(err, "error creating %s", directory) + } + } + file, err := os.Create(path) + if err != nil { + return errors.Wrapf(err, "error creating %s", path) + } + defer func() { _ = file.Close() }() + + if err := Write(file, FormatFor(path), report); err != nil { + return err + } + return errors.Wrapf(file.Close(), "error closing %s", path) +} + +// Write writes a report in the given format. +func Write(w io.Writer, format Format, report Report) error { + if format == FormatJSON { + return writeJSON(w, report) + } + return writeJUnit(w, report) +} + +// writeJSON writes the report as JSON, with the byte payloads hex-encoded so the document is +// text a human can also read. +func writeJSON(w io.Writer, report Report) error { + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + return errors.Wrap(encoder.Encode(asJSON(report)), "error writing the JSON report") +} + +// writeJUnit writes the report as JUnit XML. +func writeJUnit(w io.Writer, report Report) error { + if _, err := io.WriteString(w, xml.Header); err != nil { + return errors.Wrap(err, "error writing the report header") + } + encoder := xml.NewEncoder(w) + encoder.Indent("", " ") + if err := encoder.Encode(asJUnit(report)); err != nil { + return errors.Wrap(err, "error writing the JUnit report") + } + if err := encoder.Flush(); err != nil { + return errors.Wrap(err, "error flushing the JUnit report") + } + _, err := io.WriteString(w, "\n") + return errors.Wrap(err, "error writing the report") +} + +// suiteName is what CI shows as the suite. The capture's base name, because the full path is +// usually a build-agent temporary directory that means nothing to a reader. +func (r Report) suiteName() string { + if r.Capture == "" { + return "capture" + } + return filepath.Base(r.Capture) +} + +// detail is the body of a failure: what went wrong, and the bytes it went wrong on. +// +// The bytes are the reason this feature exists. A report saying "packet 9 failed to parse" sends +// the reader back to the capture; one carrying the payload lets them see the problem, and for a +// byte mismatch it names the offset the two first differ at, which is the single most useful +// number the tool produces. +func detail(found finding.Finding) string { + var out strings.Builder + if found.Reason != "" { + out.WriteString(found.Reason + "\n") + } + if found.Verdict == finding.VerdictBytesDiffer && found.DiffOffset >= 0 { + out.WriteString("first difference at offset " + strconv.Itoa(found.DiffOffset) + "\n") + } + if len(found.Original) > 0 { + out.WriteString("\noriginal (" + strconv.Itoa(len(found.Original)) + " bytes)\n") + out.WriteString(hex.Dump(found.Original)) + } + if len(found.Reserialized) > 0 { + out.WriteString("\nreserialized (" + strconv.Itoa(len(found.Reserialized)) + " bytes)\n") + out.WriteString(hex.Dump(found.Reserialized)) + } + return sanitise(out.String()) +} + +// sanitise replaces the characters XML 1.0 cannot carry with a readable stand-in. +// +// It is about legibility rather than validity, and the distinction is worth stating because it +// is easy to assume otherwise. encoding/xml never emits an invalid document: handed a control +// character it substitutes U+FFFD and reports no error (verified against Go 1.27). So the file +// parses either way -- what it does not do is read, because a codec error mentioning a control +// byte arrives as three bytes of mojibake in the middle of the sentence explaining the failure. +// A full stop says the same thing and costs nothing. +// +// The payloads are hex-encoded and never need this; a codec's error message is arbitrary text +// and does. +func sanitise(text string) string { + return strings.Map(func(r rune) rune { + switch { + case r == '\t' || r == '\n' || r == '\r': + return r + case r < 0x20, r == 0x7F: + return '.' + // The XML 1.0 forbidden ranges beyond the C0 controls. + case r >= 0x80 && r <= 0x9F, r == 0xFFFE, r == 0xFFFF: + return '.' + default: + return r + } + }, text) +} diff --git a/plc4go/tools/plc4xpcapanalyzer/internal/report/report_test.go b/plc4go/tools/plc4xpcapanalyzer/internal/report/report_test.go new file mode 100644 index 000000000..928563814 --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/internal/report/report_test.go @@ -0,0 +1,311 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package report + +import ( + "bytes" + "encoding/json" + "encoding/xml" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/finding" +) + +// sampleReport is one of each verdict, which is what makes the mapping worth asserting. +func sampleReport() Report { + return Report{ + Capture: "/tmp/build-agent-42/captures/cbus.pcap", + Protocol: "c-bus", + Started: time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC), + Elapsed: 1500 * time.Millisecond, + Counters: finding.Counters{Walked: 5, Parsed: 3, ParseFail: 1, CompareFail: 1, Skipped: 1, TotalInCapture: 7}, + Findings: []finding.Finding{ + {Number: 1, Protocol: "c-bus", Summary: "CBusMessageToServer", Verdict: finding.VerdictOK, + Original: []byte("~~~\r"), Reserialized: []byte("~~~\r"), DiffOffset: -1}, + {Number: 2, Protocol: "c-bus", Summary: "CBusMessageToClient", Verdict: finding.VerdictBytesDiffer, + Reason: "3 bytes differ", Original: []byte("abcdef"), Reserialized: []byte("abcXYZ"), DiffOffset: 3}, + {Number: 3, Protocol: "c-bus", Verdict: finding.VerdictParseFail, + Reason: "no command type could be found", Original: []byte("AFFE!"), DiffOffset: -1}, + {Number: 4, Protocol: "c-bus", Verdict: finding.VerdictSkipped, Reason: "echo", + Original: []byte("!"), DiffOffset: -1}, + {Number: 5, Protocol: "c-bus", Summary: "CBusMessageToServer", Verdict: finding.VerdictSerializeFail, + Reason: "cannot write", Original: []byte("x"), DiffOffset: -1}, + // Neither of these reached a codec, so neither is the codec's business. + {Number: 6, Protocol: "c-bus", Verdict: finding.VerdictFiltered, Reason: "not our port", DiffOffset: -1}, + {Number: 7, Protocol: "c-bus", Verdict: finding.VerdictNoPayload, Reason: "no application layer", DiffOffset: -1}, + }, + } +} + +// parseJUnit reads a report back with a general-purpose XML parser, which is what makes it a +// test of the document rather than of our own writer. +func parseJUnit(t *testing.T, document []byte) junitSuites { + t.Helper() + var parsed junitSuites + require.NoError(t, xml.Unmarshal(document, &parsed), "the document has to be well-formed XML") + return parsed +} + +// TestTheJUnitDocumentIsWellFormed is the whole point: a report CI cannot parse is worth less +// than no report, because it looks like a passing build. +func TestTheJUnitDocumentIsWellFormed(t *testing.T) { + out := &bytes.Buffer{} + require.NoError(t, Write(out, FormatJUnit, sampleReport())) + + assert.True(t, strings.HasPrefix(out.String(), xml.Header), + "a JUnit consumer expects the declaration") + + // Parsed by a decoder that knows nothing about us, and every token accounted for. + decoder := xml.NewDecoder(bytes.NewReader(out.Bytes())) + for { + _, err := decoder.Token() + if err == io.EOF { + break + } + require.NoError(t, err, "every token has to parse") + } +} + +// TestEachVerdictBecomesTheRightJUnitOutcome pins the mapping a reader depends on. A skip +// reported as a failure is noise; a failure reported as a skip is a false clean bill of health. +func TestEachVerdictBecomesTheRightJUnitOutcome(t *testing.T) { + parsed := parseJUnit(t, mustJUnit(t, sampleReport())) + require.Len(t, parsed.Suites, 1) + suite := parsed.Suites[0] + + byName := map[string]junitCase{} + for _, one := range suite.Cases { + byName[one.Name] = one + } + + // A clean round trip is a passing test case: no failure, no skip. + ok := byName["packet 1 CBusMessageToServer"] + require.NotZero(t, ok.Name, "a clean packet still has to appear, or the report cannot say how much was checked") + assert.Nil(t, ok.Failure) + assert.Nil(t, ok.Skipped) + + // The three defects are failures, typed by what went wrong. + for name, wantType := range map[string]string{ + "packet 2 CBusMessageToClient": "bytes", + "packet 3": "parse", + "packet 5 CBusMessageToServer": "serial", + } { + one := byName[name] + require.NotNil(t, one.Failure, "%s has to be a failure", name) + assert.Equal(t, wantType, one.Failure.Type, "%s", name) + assert.Nil(t, one.Skipped, "%s must not also be a skip", name) + } + + // The skip is a skip, carrying the reason. + skipped := byName["packet 4"] + require.NotNil(t, skipped.Skipped, "a skipped packet has to be a skip, not a failure") + assert.Nil(t, skipped.Failure) + assert.Equal(t, "echo", skipped.Skipped.Message) + + // And what never reached a codec is not the codec's business. + assert.NotContains(t, byName, "packet 6", "a filtered packet must not be reported") + assert.NotContains(t, byName, "packet 7", "nor one with no application layer") +} + +// TestTheTotalsMatchTheCases is the arithmetic a CI dashboard shows. Attributes that disagree +// with the cases beneath them make the report untrustworthy in a way nobody investigates. +func TestTheTotalsMatchTheCases(t *testing.T) { + parsed := parseJUnit(t, mustJUnit(t, sampleReport())) + suite := parsed.Suites[0] + + failures, skips := 0, 0 + for _, one := range suite.Cases { + if one.Failure != nil { + failures++ + } + if one.Skipped != nil { + skips++ + } + } + + assert.Equal(t, len(suite.Cases), suite.Tests, "tests has to count the cases") + assert.Equal(t, failures, suite.Failures) + assert.Equal(t, skips, suite.Skipped) + assert.Equal(t, 3, failures, "the sample has three defects in it") + assert.Equal(t, 1, skips) + + // And the top level agrees with its only suite. + assert.Equal(t, suite.Tests, parsed.Tests) + assert.Equal(t, suite.Failures, parsed.Failures) + assert.Equal(t, suite.Skipped, parsed.Skipped) +} + +// TestAFailureCarriesTheBytes is the reason to write a report at all. "Packet 2 failed" sends +// the reader back to the capture; the payloads and the offset let them see the problem. +func TestAFailureCarriesTheBytes(t *testing.T) { + parsed := parseJUnit(t, mustJUnit(t, sampleReport())) + + var mismatch *junitFailure + for _, one := range parsed.Suites[0].Cases { + if one.Name == "packet 2 CBusMessageToClient" { + mismatch = one.Failure + } + } + require.NotNil(t, mismatch) + + assert.Contains(t, mismatch.Message, "offset 3", "the message names where they differ") + assert.Contains(t, mismatch.Detail, "first difference at offset 3") + assert.Contains(t, mismatch.Detail, "original (6 bytes)") + assert.Contains(t, mismatch.Detail, "reserialized (6 bytes)") + assert.Contains(t, mismatch.Detail, "61 62 63 64 65 66", "the captured bytes, in hex") + assert.Contains(t, mismatch.Detail, "61 62 63 58 59 5a", "and what the codec wrote back") +} + +// TestAControlCharacterStaysReadable is about legibility, not validity: encoding/xml never +// emits an invalid document, and handed a control character it substitutes U+FFFD without +// complaint. That leaves three bytes of mojibake in the middle of the sentence explaining a +// failure, so a control byte becomes a full stop instead, which says the same thing. +func TestAControlCharacterStaysReadable(t *testing.T) { + hostile := sampleReport() + hostile.Findings = []finding.Finding{{ + Number: 1, + Protocol: "c-bus", + Verdict: finding.VerdictParseFail, + Reason: "bad byte \x00 then \x07 then \x1b[31m and a form feed \x0c", + Original: []byte{0x00, 0x1b, 0xff}, + DiffOffset: -1, + }} + + document := mustJUnit(t, hostile) + parsed := parseJUnit(t, document) + require.Len(t, parsed.Suites[0].Cases, 1) + + failure := parsed.Suites[0].Cases[0].Failure + require.NotNil(t, failure) + for _, forbidden := range []string{"\x00", "\x07", "\x1b", "\x0c"} { + assert.NotContains(t, failure.Message, forbidden, "the message must not carry %q", forbidden) + assert.NotContains(t, failure.Detail, forbidden, "the detail must not carry %q", forbidden) + } + // And not the replacement character either, which is what encoding/xml would have left + // behind and is the thing this exists to avoid. + assert.NotContains(t, failure.Message, "\uFFFD", + "a control byte should read as a full stop, not as mojibake") + assert.NotContains(t, failure.Detail, "\uFFFD") + assert.Contains(t, failure.Message, "bad byte . then . then .[31m", + "the sentence explaining the failure has to survive intact") + assert.Contains(t, failure.Detail, "[31m", "an escape's payload is text and stays") +} + +// TestAnAbortedRunSaysSo matters because the findings are then a prefix of the capture. A report +// that did not say so would read as a clean bill of health for packets nobody looked at. +func TestAnAbortedRunSaysSo(t *testing.T) { + for _, aborted := range []bool{true, false} { + partial := sampleReport() + partial.Aborted = aborted + + parsed := parseJUnit(t, mustJUnit(t, partial)) + properties := map[string]string{} + for _, property := range parsed.Suites[0].Properties { + properties[property.Name] = property.Value + } + + assert.Equal(t, strconv.FormatBool(aborted), properties["aborted"], + "the report has to say whether the findings are the whole capture") + } +} + +// TestThePropertiesDescribeTheRun covers the context a reader needs and no finding carries: how +// much of the capture there was, and how much of it was examined. +func TestThePropertiesDescribeTheRun(t *testing.T) { + parsed := parseJUnit(t, mustJUnit(t, sampleReport())) + properties := map[string]string{} + for _, property := range parsed.Suites[0].Properties { + properties[property.Name] = property.Value + } + + assert.Equal(t, "c-bus", properties["protocol"]) + assert.Equal(t, "/tmp/build-agent-42/captures/cbus.pcap", properties["capture"]) + assert.Equal(t, "7", properties["packetsInCapture"], "a property of the capture, not of a finding") + assert.Equal(t, "5", properties["packetsWalked"]) + + // The suite is named for the capture's base name: the full path is usually a build agent's + // temporary directory and means nothing to a reader. + assert.Equal(t, "cbus.pcap", parsed.Suites[0].Name) + assert.Equal(t, "1.500", parsed.Suites[0].Time, "JUnit wants seconds") + assert.Equal(t, "2024-01-02T03:04:05", parsed.Suites[0].Timestamp) +} + +// TestTheFormatFollowsTheExtension is the whole interface to choosing one. +func TestTheFormatFollowsTheExtension(t *testing.T) { + for path, want := range map[string]Format{ + "report.xml": FormatJUnit, + "target/surefire-reports/go.xml": FormatJUnit, + "report.json": FormatJSON, + "REPORT.JSON": FormatJSON, + "report": FormatJUnit, + "report.txt": FormatJUnit, + } { + assert.Equal(t, want, FormatFor(path), path) + } +} + +// TestTheJSONReportCarriesTheSameFacts is the alternative for anything that would rather not +// read XML. Hex-encoded payloads, so the file stays text. +func TestTheJSONReportCarriesTheSameFacts(t *testing.T) { + out := &bytes.Buffer{} + require.NoError(t, Write(out, FormatJSON, sampleReport())) + + var parsed jsonReport + require.NoError(t, json.Unmarshal(out.Bytes(), &parsed), "the document has to be valid JSON") + + assert.Equal(t, "c-bus", parsed.Protocol) + assert.Equal(t, 7, parsed.Counters.TotalInCapture) + // Everything, including what never reached a codec: unlike JUnit this is not a test report, + // so there is no reason to hide packets from it. + require.Len(t, parsed.Findings, 7) + assert.Equal(t, "bytes", parsed.Findings[1].Verdict) + assert.True(t, parsed.Findings[1].Issue) + assert.Equal(t, 3, parsed.Findings[1].DiffOffset) + assert.Equal(t, "616263646566", parsed.Findings[1].Original) + assert.False(t, parsed.Findings[3].Issue, "a skip is not an issue") +} + +// TestWriteFileCreatesTheDirectory covers the papercut: the natural place to write a report is +// target/surefire-reports, which does not exist until something makes it. +func TestWriteFileCreatesTheDirectory(t *testing.T) { + path := filepath.Join(t.TempDir(), "target", "surefire-reports", "analyzer.xml") + require.NoError(t, WriteFile(path, sampleReport())) + + written, err := os.ReadFile(path) + require.NoError(t, err) + assert.NotEmpty(t, parseJUnit(t, written).Suites) +} + +// mustJUnit renders a report and fails the test if it cannot. +func mustJUnit(t *testing.T, report Report) []byte { + t.Helper() + out := &bytes.Buffer{} + require.NoError(t, Write(out, FormatJUnit, report)) + return out.Bytes() +} diff --git a/plc4go/tools/plc4xpcapanalyzer/main.go b/plc4go/tools/plc4xpcapanalyzer/main.go index e13b0bf92..aaf4a728b 100644 --- a/plc4go/tools/plc4xpcapanalyzer/main.go +++ b/plc4go/tools/plc4xpcapanalyzer/main.go @@ -19,7 +19,7 @@ package main -import "github.com/apache/plc4x/plc4go-extras/tools/plc4xpcapanalyzer/cmd" +import "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/cmd" func main() { cmd.Execute() diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/actions.go b/plc4go/tools/plc4xpcapanalyzer/ui/actions.go deleted file mode 100644 index 3e35cf79b..000000000 --- a/plc4go/tools/plc4xpcapanalyzer/ui/actions.go +++ /dev/null @@ -1,152 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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. - */ - -package ui - -import ( - "bytes" - "fmt" - "github.com/apache/plc4x/plc4go/pkg/api/drivers" - "os" - "path" - "strings" - - plc4go "github.com/apache/plc4x/plc4go/pkg/api" - "github.com/apache/plc4x/plc4go/spi" - "github.com/apache/plc4x/plc4go/spi/transports/pcap" - - "github.com/pkg/errors" - "github.com/rivo/tview" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" -) - -func InitSubsystem() { - logLevel := zerolog.InfoLevel - if configuredLevel := config.LogLevel; configuredLevel != "" { - if parsedLevel, err := zerolog.ParseLevel(configuredLevel); err != nil { - panic(err) - } else { - logLevel = parsedLevel - } - } - driverManager = plc4go.NewPlcDriverManager() - - log.Logger = log. - //// Enable below if you want to see the filenames - //With().Caller().Logger(). - Output(zerolog.NewConsoleWriter( - func(w *zerolog.ConsoleWriter) { - w.Out = tview.ANSIWriter(consoleOutput) - }, - func(w *zerolog.ConsoleWriter) { - w.FormatFieldValue = func(i interface{}) string { - if aString, ok := i.(string); ok && strings.Contains(aString, "\\n") { - return fmt.Sprintf("\x1b[%dm%v\x1b[0m", 31, "see below") - } - return fmt.Sprintf("%s", i) - } - w.FormatExtra = func(m map[string]interface{}, buffer *bytes.Buffer) error { - for key, i := range m { - if aString, ok := i.(string); ok && strings.Contains(aString, "\n") { - buffer.WriteString("\n") - buffer.WriteString(fmt.Sprintf("\x1b[%dm%v\x1b[0m", 32, "field "+key)) - buffer.WriteString(":\n" + aString) - } - } - return nil - } - }, - )). - Level(logLevel) - - // We offset the commands executed with the last commands - commandsExecuted = len(config.History.Last10Commands) - outputCommandHistory() - - for _, driver := range config.AutoRegisterDrivers { - log.Info().Str("driver", driver).Msg("Auto register driver") - if err := validateDriverParam(driver); err != nil { - log.Err(err).Msg("Invalid configuration") - continue - } - _ = registerDriver(driver) - } -} - -func OpenFile(pcapFile string) error { - if !strings.HasPrefix(pcapFile, string(os.PathSeparator)) { - pcapFile = path.Join(currentDir, pcapFile) - } - var name string - if stat, err := os.Stat(pcapFile); err != nil { - return err - } else if stat.IsDir() { - stat.Name() - return errors.Errorf("%s is a dir", pcapFile) - } else { - name = stat.Name() - } - for _, loadedPcapFile := range loadedPcapFiles { - if loadedPcapFile.path == pcapFile { - return errors.Errorf("%s already loaded", name) - } - } - addRecentFilesEntry(pcapFile) - loadedPcapFiles = append(loadedPcapFiles, loadedPcapFile{ - name: name, - path: pcapFile, - }) - loadedPcapFilesChanged() - return nil -} - -func outputCommandHistory() { - _, _ = fmt.Fprintln(commandOutput, "[#0000ff]Last 10 commands[white]") - for i, command := range config.History.Last10Commands { - _, _ = fmt.Fprintf(commandOutput, " [#00ff00]%d[white]: [\"%d\"]%s[\"\"]\n", i, i, tview.Escape(command)) - } -} - -func validateDriverParam(driver string) error { - for _, protocol := range protocolList { - if protocol == driver { - return nil - } - } - return errors.Errorf("protocol %s not found", driver) -} - -func registerDriver(driver string) error { - switch driver { - case "ads": - drivers.RegisterAdsDriver(driverManager) - case "bacnetip": - drivers.RegisterBacnetDriver(driverManager) - case "c-bus": - drivers.RegisterCBusDriver(driverManager) - case "s7": - drivers.RegisterS7Driver(driverManager) - default: - return errors.Errorf("Unknown driver %s", driver) - } - driverManager.(spi.TransportAware).RegisterTransport(pcap.NewTransport()) - go driverAdded(driver) - return nil -} diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/agreement_test.go b/plc4go/tools/plc4xpcapanalyzer/ui/agreement_test.go new file mode 100644 index 000000000..233cb407f --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/ui/agreement_test.go @@ -0,0 +1,158 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package ui_test + +import ( + "context" + "io" + "path/filepath" + "testing" + + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + cliConfig "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/analyzer" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/finding" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/ui" +) + +// There are two implementations of the same analysis: internal/analyzer walks a capture once +// and the terminal interface walks it in three phases so it can report progress and stream +// records. They exist separately for a reason the interface documents -- bytes cannot be +// recovered from a log line -- and they are now expected to reach the SAME verdicts, because +// they classify, compare and count through internal/finding rather than each deciding for +// itself. +// +// This is the test that expectation earns. Before the shared package there were two taxonomies +// and nothing could compare them, which is how the documented description of the demo came to +// disagree with what the tool actually reported. + +// TestBothAnalysisPathsAgree runs the same capture through both and compares verdict by verdict. +func TestBothAnalysisPathsAgree(t *testing.T) { + capture := filepath.Join(t.TempDir(), "cbus.pcap") + require.NoError(t, pcapfixture.WriteCBus(capture, pcapfixture.CBusSessionWithFailures())) + + fromAnalyzer, analyzerCounters := analyseWithTheAnalyzer(t, capture) + fromInterface := analyseWithTheInterface(t, capture) + + require.NotEmpty(t, fromAnalyzer, "the analyzer has to report something to compare") + require.Equal(t, len(fromAnalyzer), len(fromInterface), + "the two paths have to examine the same number of packets") + + for i := range fromAnalyzer { + mine, theirs := fromAnalyzer[i], fromInterface[i] + assert.Equal(t, mine.Number, theirs.Number, "packet %d: numbering", i) + assert.Equal(t, mine.Verdict, theirs.Verdict, + "packet %d (no. %d): the analyzer says %s and the interface says %s", + i, mine.Number, mine.Verdict, theirs.Verdict) + assert.Equal(t, mine.Summary, theirs.Summary, "packet %d: message name", i) + assert.Equal(t, mine.DiffOffset, theirs.DiffOffset, "packet %d: first difference", i) + assert.Equal(t, mine.Original, theirs.Original, "packet %d: captured payload", i) + assert.Equal(t, mine.Reserialized, theirs.Reserialized, "packet %d: reserialized payload", i) + } + + // And the totals, which used to be reached by two different rules. + interfaceCounters := finding.Counters{} + for _, found := range fromInterface { + interfaceCounters.Count(found.Verdict) + } + assert.Equal(t, analyzerCounters.Walked, interfaceCounters.Walked) + assert.Equal(t, analyzerCounters.Parsed, interfaceCounters.Parsed) + assert.Equal(t, analyzerCounters.ParseFail, interfaceCounters.ParseFail) + assert.Equal(t, analyzerCounters.SerializeFail, interfaceCounters.SerializeFail) + assert.Equal(t, analyzerCounters.CompareFail, interfaceCounters.CompareFail) + assert.Equal(t, analyzerCounters.Skipped, interfaceCounters.Skipped) + assert.Equal(t, analyzerCounters.Issues(), interfaceCounters.Issues()) +} + +// analyseWithTheAnalyzer runs the command line's path and collects what it found. +func analyseWithTheAnalyzer(t *testing.T, capture string) ([]finding.Finding, finding.Counters) { + t.Helper() + pinAnalyzerGlobals(t) + cliConfig.AnalyzeConfigInstance.Client = pcapfixture.ClientIP + + var found []finding.Finding + var counters finding.Counters + err := analyzer.AnalyzeWithOptions(context.Background(), capture, protocol.CBus.Name, analyzer.Options{ + OnFinding: func(one finding.Finding) { found = append(found, one) }, + OnCounters: func(all finding.Counters) { counters = all }, + }) + require.NoError(t, err) + return found, counters +} + +// analyseWithTheInterface runs the terminal interface's path over the same capture, configured +// the same way, and reduces its records to the shared facts. +func analyseWithTheInterface(t *testing.T, capture string) []finding.Finding { + t.Helper() + pinAnalyzerGlobals(t) + + result := ui.Analyze(context.Background(), ui.Request{ + Protocol: protocol.CBus, + PcapFile: capture, + Client: pcapfixture.ClientIP, + PacketLimit: ^uint(0), + }, ui.Sink{}) + require.NoError(t, result.Err) + + found := make([]finding.Finding, 0, len(result.Records)) + for _, record := range result.Records { + found = append(found, record.Finding) + } + return found +} + +// pinAnalyzerGlobals saves and restores the configuration singletons both paths read, and +// quiets the logger the analyzer reports through. +func pinAnalyzerGlobals(t *testing.T) { + t.Helper() + saved := struct { + root cliConfig.RootConfig + pcap cliConfig.PcapConfig + analyze cliConfig.AnalyzeConfig + cbus cliConfig.CBusConfig + logger zerolog.Logger + level zerolog.Level + }{ + cliConfig.RootConfigInstance, cliConfig.PcapConfigInstance, + cliConfig.AnalyzeConfigInstance, cliConfig.CBusConfigInstance, + log.Logger, zerolog.GlobalLevel(), + } + t.Cleanup(func() { + cliConfig.RootConfigInstance = saved.root + cliConfig.PcapConfigInstance = saved.pcap + cliConfig.AnalyzeConfigInstance = saved.analyze + cliConfig.CBusConfigInstance = saved.cbus + log.Logger = saved.logger + zerolog.SetGlobalLevel(saved.level) + }) + + cliConfig.RootConfigInstance.HideProgressBar = true + // No flags are parsed here, so the limit is its zero value and the analyzer would stop + // after the first packet. + cliConfig.PcapConfigInstance.PackageNumberLimit = ^uint(0) + log.Logger = zerolog.New(io.Discard) + zerolog.SetGlobalLevel(zerolog.Disabled) +} diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/analysis.go b/plc4go/tools/plc4xpcapanalyzer/ui/analysis.go new file mode 100644 index 000000000..4d91652b0 --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/ui/analysis.go @@ -0,0 +1,584 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package ui + +import ( + "bytes" + "context" + "encoding/hex" + "fmt" + "net" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/apache/plc4x/plc4go/spi" + "github.com/apache/plc4x/plc4go/spi/errors" + "github.com/gopacket/gopacket" + "github.com/gopacket/gopacket/layers" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/progress" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/bacnetanalyzer" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/cbusanalyzer" + analyzercodec "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/codec" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/common" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/finding" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/pcaphandler" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol" +) + +// The analysis this UI draws. +// +// internal/analyzer performs the same parse, re-serialize and byte-compare loop, but it +// reports its findings only by logging them: it returns nothing but an error, and its +// per-packet detail — the original payload, the re-serialized payload and where the two first +// differ — reaches the outside world exclusively as a hex dump embedded in a zerolog field. +// A table of packets and a detail pane showing the byte difference cannot be built from that, +// and reconstructing bytes by parsing log lines would be worse than doing the work again. So +// this file walks the capture itself, through the SAME collaborators the analyzer uses — +// pcaphandler for the handle and the packet index, the per-protocol analyzers for parse and +// serialize, common for the packet metadata — and returns values instead of writing them out. +// +// Nothing here touches the Bubble Tea model. Run is called from a tea.Cmd, reports its advance +// through a progress.Reporter, and hands back one Result. + +// Phase names the stage of a run. They are what the run panel's breadcrumb shows, and they +// travel to the model as the Description of a progress.Update. +const ( + // PhaseIndex is the pass that opens the capture and numbers its packets. pcaphandler needs + // this to give a packet the same number Wireshark would show it under, even when a filter + // has removed the packets before it. + PhaseIndex = "index" + // PhaseFilter is the pass that streams the capture through the BPF filter and the + // protocol's own packet mapping, keeping the payloads that survive. + PhaseFilter = "filter" + // PhaseAnalyze is the pass that parses, re-serializes and byte-compares each payload. + PhaseAnalyze = "analyze" +) + +// Phases is the breadcrumb, in order. +var Phases = []string{PhaseIndex, PhaseFilter, PhaseAnalyze} + +// The verdict taxonomy lives in internal/finding, which is also where the analyzer gets it. +// These are aliases rather than a second definition: there used to be two, and they drifted -- +// the documentation described the demo's skipped packet as a failure while this package counted +// it as normal traffic, and no test could catch the disagreement because there was nothing +// shared for the two to disagree with. +type Verdict = finding.Verdict + +const ( + VerdictOK = finding.VerdictOK + VerdictBytesDiffer = finding.VerdictBytesDiffer + VerdictParseFail = finding.VerdictParseFail + VerdictSerializeFail = finding.VerdictSerializeFail + VerdictSkipped = finding.VerdictSkipped + VerdictFiltered = finding.VerdictFiltered + VerdictNoPayload = finding.VerdictNoPayload +) + +// Direction says which way a packet travelled, relative to the configured client address. +type Direction int + +const ( + // DirectionRequest is a packet sent by the client. + DirectionRequest Direction = iota + // DirectionResponse is a packet sent to the client. + DirectionResponse + // DirectionUnknown is a packet whose endpoints do not include the configured client, or a + // capture analysed with no client configured at all. + DirectionUnknown +) + +// Record is one analysed packet, and everything the UI shows about it. +type Record struct { + // The facts and the verdict, shared with everything else that reports them. Embedded, so a + // caller still writes record.Number and record.Verdict, and so the two carriers cannot + // acquire different ideas of what a finding consists of. + finding.Finding + // Offset is how long after the first packet of the capture this one arrived. + Offset time.Duration + // Direction is which way it travelled. + Direction Direction + // Tree is the parsed message rendered as text, for the detail pane's tree view. Only a + // screen needs it, so it stays here. + Tree string +} + +// Counters are the running totals the run panel shows, and the same totals the analyzer logs +// in its summary line. One type and one counting rule, because "parsed" was easy to disagree +// about: a packet that parsed and then failed to serialize did parse. +type Counters = finding.Counters + +// Request is one analysis, fully specified. +// +// It is a value rather than a read of the config singletons because the singletons are +// process-global: a run has to be reproducible, and a test has to be able to describe a run +// without mutating state that outlives it. +type Request struct { + Protocol protocol.Protocol + PcapFile string + // Client is the address requests originate from. Protocols where a request and a response + // are encoded differently — C-Bus is one — cannot be parsed without it. + Client string + // Filter is a BPF filter expression. Empty means the protocol's own default. + Filter string + // NoFilter disables filtering entirely, default included. + NoFilter bool + // OnlyParse stops after parsing, skipping the re-serialize and the byte compare. + OnlyParse bool + // NoBytesCompare re-serializes but does not compare. + NoBytesCompare bool + // StartPacket skips everything before this packet number. Zero starts at the beginning. + StartPacket uint + // PacketLimit caps how many packets are walked. Zero means no limit — unlike the analyzer's + // config singleton, where zero means "walk nothing", which is why the old UI's analyze + // command produced an empty run unless the CLI's analyze flags had been parsed first. + PacketLimit uint +} + +// Label is the command line that would reproduce this run. It titles the run panel. +func (r Request) Label() string { + return "analyze " + r.Protocol.Name + " " + filepath.Base(r.PcapFile) +} + +// filterExpression resolves the BPF filter this run should use. +func (r Request) filterExpression() string { + if r.NoFilter { + return "" + } + if r.Filter != "" { + return r.Filter + } + // The protocol's own default, so that opening a capture in the interface selects the same + // packets the command line would. Without it every packet in the capture is handed to the + // codec, including the ones belonging to other protocols. + if protocolCodec, known := analyzercodec.For(r.Protocol.Name); known { + return protocolCodec.DefaultFilter() + } + return "" +} + +// Result is the outcome of one run. +type Result struct { + Request Request + Records []Record + Counters Counters + // Err is set when the run could not be performed at all: an unreadable capture, an + // unsupported protocol. A packet that failed to parse is a Record, not an Err. + Err error + // Aborted says the caller's context was cancelled part way through. The records collected + // before that point are still returned, because a half-finished analysis of a large + // capture is worth keeping. + Aborted bool + // Elapsed is how long the run took. + Elapsed time.Duration +} + +// codec is the per-protocol half of a run. +type codec struct { + parse func(common.PacketInformation, []byte) (spi.Message, error) + serialize func(spi.Message) ([]byte, error) + // mapPackets wraps the packet stream in whatever re-assembly the protocol needs. C-Bus + // splits messages across TCP segments, so it merges them here. + mapPackets func(chan gopacket.Packet, func(gopacket.Packet) common.PacketInformation) chan gopacket.Packet + // wait blocks until any goroutine the mapping started has finished, so that nothing is + // still running once Run returns. + wait func() +} + +// codecFor builds the codec for a request. cancel is called when the run finishes, to release +// the mapping goroutine; the caller must arrange for it to run on every exit path. +func codecFor(ctx context.Context, request Request) (codec, context.CancelFunc, error) { + switch request.Protocol.Name { + case protocol.BacnetIP.Name: + return codec{ + parse: bacnetanalyzer.PackageParse, + serialize: bacnetanalyzer.SerializePackage, + mapPackets: passthroughMapping, + wait: func() {}, + }, func() {}, nil + + case protocol.CBus.Name: + analyzer := &cbusanalyzer.Analyzer{Client: net.ParseIP(request.Client)} + analyzer.Init() + // A context of our own so the mapping goroutine is released on every early exit — the + // packet limit, an abort, a read error. Without it that goroutine blocks on a send for + // the life of the process and keeps logging into the UI's log pane. + mappingCtx, cancel := context.WithCancel(ctx) + return codec{ + parse: analyzer.PackageParse, + serialize: analyzer.SerializePackage, + mapPackets: func(in chan gopacket.Packet, info func(gopacket.Packet) common.PacketInformation) chan gopacket.Packet { + return analyzer.MapPackets(mappingCtx, in, info) + }, + wait: analyzer.WaitForMapping, + }, cancel, nil + + default: + // Every other protocol is a codec and a filter, from internal/codec -- the same + // registry the command line dispatches through, so the interface and the command line + // cannot come to support different sets. + protocolCodec, known := analyzercodec.For(request.Protocol.Name) + if !known { + return codec{}, func() {}, errors.Errorf("protocol %s is registered but not implemented by the analyzer", request.Protocol.Name) + } + client := net.ParseIP(request.Client) + return codec{ + parse: func(info common.PacketInformation, payload []byte) (spi.Message, error) { + response, _ := protocolCodec.IsResponse(info, client) + return protocolCodec.Parse(ctx, payload, response) + }, + serialize: func(message spi.Message) ([]byte, error) { + return protocolCodec.Serialize(ctx, message) + }, + mapPackets: passthroughMapping, + wait: func() {}, + }, func() {}, nil + } +} + +// passthroughMapping is the mapping for protocols that need no re-assembly. +func passthroughMapping(in chan gopacket.Packet, _ func(gopacket.Packet) common.PacketInformation) chan gopacket.Packet { + return in +} + +// collected is one packet that survived the filter pass, held until the analyze pass. +type collected struct { + info common.PacketInformation + payload []byte + // filtered is the reason the protocol mapping rejected the packet, nil when it did not. + filtered error +} + +// Sink is where a run publishes what it learns while it is still running. +// +// Every member is optional and every member is called from the analysis goroutine, so a +// consumer inside a Bubble Tea program must do nothing in them but hand the value to a +// channel. Nothing here may touch model state. +type Sink struct { + // Progress receives the advance of each phase. + Progress progress.Reporter + // Observe is handed each record as it is produced, in packet order, so that the table + // fills while the run is in flight rather than all at once at the end. + Observe func(Record) + // Clock measures the elapsed time. Tests inject one they control. + Clock func() time.Time +} + +// Analyze performs one analysis and returns what it found. +// +// It is deliberately synchronous and free of any UI type: the model calls it from a tea.Cmd +// and receives the Result as a message. +func Analyze(ctx context.Context, request Request, sink Sink) Result { + reporter := sink.Progress + if reporter == nil { + reporter = progress.Nop() + } + clock := sink.Clock + if clock == nil { + clock = time.Now + } + started := clock() + result := Result{Request: request} + finish := func() Result { + result.Elapsed = clock().Sub(started) + return result + } + + defer reporter.Done() + + // aborted records a cancellation and says whether to stop. It is checked between the + // phases as well as inside their loops: a context cancelled before a phase starts makes + // that phase's producer close its channel immediately, so the loop body never runs and + // the cancellation would otherwise go unnoticed and the run would look complete. + aborted := func() bool { + if ctx.Err() == nil { + return false + } + result.Aborted = true + return true + } + + codec, cancelMapping, err := codecFor(ctx, request) + if err != nil { + result.Err = err + return finish() + } + defer func() { + cancelMapping() + codec.wait() + }() + + if aborted() { + return finish() + } + + // Index. The total is not known until this pass has run, so the bar starts indeterminate. + reporter.Start(0, PhaseIndex) + handle, total, timestampToIndex, err := pcaphandler.GetIndexedPcapHandle(request.PcapFile, request.filterExpression()) + if err != nil { + result.Err = errors.Wrapf(err, "error opening capture %s", request.PcapFile) + return finish() + } + defer handle.Close() + result.Counters.TotalInCapture = total + + // Filter. Everything that survives is copied out of the capture buffer, because the analyze + // pass reads it after the handle has moved on. + reporter.Start(total, PhaseFilter) + packetInformationFor := func(packet gopacket.Packet) common.PacketInformation { + return packetInformation(request.PcapFile, packet, timestampToIndex) + } + var packets []collected + walked := uint(0) + if aborted() { + return finish() + } + for packet := range codec.mapPackets(pcaphandler.GetPacketSource(handle).Packets(), packetInformationFor) { + if aborted() { + break + } + if packet == nil { + break + } + walked++ + // The same boundary the analyzer uses: StartPacket is the first packet KEPT, not the + // last one skipped. + if request.StartPacket > 0 && walked < request.StartPacket { + continue + } + if request.PacketLimit > 0 && walked > request.PacketLimit { + break + } + reporter.Advance(1) + + info := packetInformationFor(packet) + if filtered, ok := packet.(common.FilteredPackage); ok { + packets = append(packets, collected{info: info, filtered: filtered.FilterReason()}) + continue + } + applicationLayer := packet.ApplicationLayer() + if applicationLayer == nil { + packets = append(packets, collected{info: info}) + continue + } + packets = append(packets, collected{info: info, payload: bytes.Clone(applicationLayer.Payload())}) + } + + // Analyze. + if aborted() { + return finish() + } + reporter.Start(len(packets), PhaseAnalyze) + var base time.Time + for i, packet := range packets { + if aborted() { + break + } + reporter.Advance(1) + if i == 0 { + base = packet.info.PacketTimestamp + } + record := analyseOne(request, codec, packet, base) + result.Counters.Count(record.Verdict) + result.Records = append(result.Records, record) + if sink.Observe != nil { + sink.Observe(record) + } + } + return finish() +} + +// analyseOne turns one collected packet into a record. +func analyseOne(request Request, codec codec, packet collected, base time.Time) Record { + record := Record{ + Number: packet.info.PacketNumber, + Offset: packet.info.PacketTimestamp.Sub(base), + Direction: directionOf(packet.info, request.Client), + Protocol: request.Protocol.Name, + Original: packet.payload, + DiffOffset: -1, + } + switch { + case packet.filtered != nil: + record.Verdict = VerdictFiltered + record.Reason = packet.filtered.Error() + return record + case len(packet.payload) == 0: + record.Verdict = VerdictNoPayload + record.Reason = "no application layer" + return record + } + + parsed, err := codec.parse(packet.info, packet.payload) + if err != nil { + record.Verdict, record.Reason = finding.Classify(err) + return record + } + + record.Summary = messageName(parsed) + record.Tree = fmt.Sprintf("%v", parsed) + if request.OnlyParse { + record.Verdict = VerdictOK + return record + } + + serialized, err := codec.serialize(parsed) + if err != nil { + record.Verdict = VerdictSerializeFail + record.Reason = err.Error() + return record + } + record.Reserialized = serialized + if request.NoBytesCompare { + record.Verdict = VerdictOK + return record + } + if offset := FirstDifference(record.Original, record.Reserialized); offset >= 0 { + record.Verdict = VerdictBytesDiffer + record.DiffOffset = offset + record.Reason = strconv.Itoa(differingBytes(record.Original, record.Reserialized)) + " bytes differ" + return record + } + record.Verdict = VerdictOK + return record +} + +// FirstDifference and differingBytes live in internal/finding, so that the offset a report +// names and the offset the detail pane points its caret at are the same number by construction. +var ( + FirstDifference = finding.FirstDifference + differingBytes = finding.DifferingBytes +) + +// directionOf decides which way a packet travelled. +func directionOf(info common.PacketInformation, client string) Direction { + if client == "" || info.SrcIp == nil { + return DirectionUnknown + } + clientIP := net.ParseIP(client) + if clientIP == nil { + return DirectionUnknown + } + switch { + case info.SrcIp.Equal(clientIP): + return DirectionRequest + case info.DstIp != nil && info.DstIp.Equal(clientIP): + return DirectionResponse + default: + return DirectionUnknown + } +} + +// messageName lives in internal/finding, because it is part of what a finding says: a report +// and a packet table naming the same message differently would be a bug in both. +var messageName = finding.MessageName + +// packetInformation builds the metadata the codecs need, the same way the analyzer does. +func packetInformation(pcapFile string, packet gopacket.Packet, timestampToIndex map[time.Time]int) common.PacketInformation { + timestamp := packet.Metadata().Timestamp + number := timestampToIndex[timestamp] + information := common.PacketInformation{ + PacketNumber: number, + PacketTimestamp: timestamp, + Description: fmt.Sprintf("No.[%d] timestamp: %v, %s", number, timestamp, pcapFile), + } + if network, ok := packet.NetworkLayer().(*layers.IPv4); ok { + information.SrcIp = network.SrcIP + information.DstIp = network.DstIP + } + switch transport := packet.TransportLayer().(type) { + case *layers.TCP: + information.SrcPort, information.DstPort = int(transport.SrcPort), int(transport.DstPort) + case *layers.UDP: + information.SrcPort, information.DstPort = int(transport.SrcPort), int(transport.DstPort) + } + return information +} + +// HexDumpLines renders data as offset-prefixed rows of bytesPerRow bytes, with the printable +// rendering on the right. It is hex.Dump's shape at a configurable row width, because the +// detail pane has to fit two dumps side by side in a pane that is often under sixty cells. +func HexDumpLines(data []byte, bytesPerRow, maxRows int) []string { + if bytesPerRow < 1 { + bytesPerRow = 8 + } + var lines []string + for offset := 0; offset < len(data) && len(lines) < maxRows; offset += bytesPerRow { + end := min(offset+bytesPerRow, len(data)) + lines = append(lines, hexRow(data, offset, end, bytesPerRow)) + } + if len(lines) == 0 { + lines = append(lines, "(empty)") + } + return lines +} + +// hexRow renders one row of a hex dump. +func hexRow(data []byte, from, to, bytesPerRow int) string { + var row strings.Builder + row.WriteString(fmt.Sprintf("%08x ", from)) + for i := range bytesPerRow { + if from+i < to { + row.WriteString(hex.EncodeToString(data[from+i : from+i+1])) + row.WriteString(" ") + } else { + row.WriteString(" ") + } + } + row.WriteString(" |") + for i := from; i < to; i++ { + row.WriteByte(printable(data[i])) + } + row.WriteString("|") + return row.String() +} + +// hexRowCaretColumn is the column a caret has to sit in to point at the byte at offset within +// its row, given the row layout hexRow produces. +func hexRowCaretColumn(offset, bytesPerRow int) int { + const offsetPrefix = len("00000000 ") + return offsetPrefix + (offset%bytesPerRow)*3 +} + +// printable maps a byte onto something safe to draw in a terminal. +func printable(b byte) byte { + if b < 0x20 || b > 0x7e { + return '.' + } + return b +} + +// Preview renders a payload the way the detail pane's first two rows show it: as a quoted Go +// string, truncated to width cells. Quoting rather than dumping is what makes a C-Bus +// exchange readable at a glance, since C-Bus is an ASCII protocol. +func Preview(data []byte, width int, ellipsis string) string { + if len(data) == 0 { + return `""` + } + quoted := strconv.Quote(string(data)) + if width <= 0 || len([]rune(quoted)) <= width { + return quoted + } + runes := []rune(quoted) + keep := max(width-len([]rune(ellipsis)), 0) + return string(runes[:keep]) + ellipsis +} diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/analysis_test.go b/plc4go/tools/plc4xpcapanalyzer/ui/analysis_test.go new file mode 100644 index 000000000..24817050b --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/ui/analysis_test.go @@ -0,0 +1,420 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package ui + +import ( + "context" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/progress" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol" +) + +// These tests drive the analysis with real protocol bytes, generated by the same fixture +// builder demo mode uses. Nothing here needs a capture file on disk beforehand, a network, or +// a device. + +// recordingReporter records what an analysis told it, so a test can assert the reporter is +// actually driven. It is mutex-guarded because the reporter is called from whichever goroutine +// is doing the work and the race detector runs over this package. +type recordingReporter struct { + mu sync.Mutex + phases []string + totals []int + advanced int + doneCalls int + lastProgress progress.Update +} + +func (r *recordingReporter) Start(total int, description string) { + r.mu.Lock() + defer r.mu.Unlock() + r.phases = append(r.phases, description) + r.totals = append(r.totals, total) +} + +func (r *recordingReporter) Advance(delta int) { + r.mu.Lock() + defer r.mu.Unlock() + r.advanced += delta +} + +func (r *recordingReporter) SetDescription(string) {} + +func (r *recordingReporter) Done() { + r.mu.Lock() + defer r.mu.Unlock() + r.doneCalls++ +} + +func (r *recordingReporter) snapshot() recordingReporter { + r.mu.Lock() + defer r.mu.Unlock() + return recordingReporter{ + phases: append([]string(nil), r.phases...), + totals: append([]int(nil), r.totals...), + advanced: r.advanced, + doneCalls: r.doneCalls, + lastProgress: r.lastProgress, + } +} + +// writeCBus writes a C-Bus capture into a temporary directory and returns the request that +// analyses it. +func writeCBus(t *testing.T, packets []pcapfixture.Packet) Request { + t.Helper() + path := filepath.Join(t.TempDir(), "cbus.pcap") + require.NoError(t, pcapfixture.WriteCBus(path, packets)) + return Request{Protocol: protocol.CBus, PcapFile: path, Client: pcapfixture.ClientIP} +} + +func TestAnalyzeProducesARecordPerPacket(t *testing.T) { + pinGlobals(t) + request := writeCBus(t, pcapfixture.CBusSession()) + + result := Analyze(t.Context(), request, Sink{}) + + require.NoError(t, result.Err) + assert.Len(t, result.Records, len(pcapfixture.CBusSession()), + "every packet in the capture has to reach the table, whatever the codec made of it") + assert.Equal(t, len(pcapfixture.CBusSession()), result.Counters.Walked) + for i, record := range result.Records { + assert.Positive(t, record.Number, "record %d must carry the packet number a user can compare with Wireshark", i) + assert.Equal(t, protocol.CBus.Name, record.Protocol) + assert.NotEmpty(t, record.Original, "record %d must keep the captured payload for the detail pane", i) + } +} + +// TestAnalyzeRoundTripsTheCleanSession is the property that makes the fixtures worth having: +// the analyzer's whole job is parse, re-serialize, compare, and a session built from real +// protocol bytes has to come back byte-identical. +func TestAnalyzeRoundTripsTheCleanSession(t *testing.T) { + pinGlobals(t) + request := writeCBus(t, pcapfixture.CBusSession()) + + result := Analyze(t.Context(), request, Sink{}) + + require.NoError(t, result.Err) + assert.Zero(t, result.Counters.ParseFail) + assert.Zero(t, result.Counters.SerializeFail) + assert.Zero(t, result.Counters.CompareFail) + assert.Zero(t, result.Counters.Issues(), "a clean session must produce no findings") + for _, record := range result.Records { + if record.Verdict == VerdictOK { + assert.Equal(t, -1, record.DiffOffset, "packet %d compared equal, so it has no difference offset", record.Number) + assert.Equal(t, record.Original, record.Reserialized) + } + } +} + +// TestAnalyzeReportsTheBrokenPayloads is the other half: the deliberately broken payloads have +// to be counted as findings rather than quietly skipped. +func TestAnalyzeReportsTheBrokenPayloads(t *testing.T) { + pinGlobals(t) + clean := Analyze(t.Context(), writeCBus(t, pcapfixture.CBusSession()), Sink{}) + broken := Analyze(t.Context(), writeCBus(t, pcapfixture.CBusSessionWithFailures()), Sink{}) + + require.NoError(t, clean.Err) + require.NoError(t, broken.Err) + assert.Positive(t, broken.Counters.Issues(), + "the failure fixture exists so that the findings tab and the failure counters have something in them") + assert.Greater(t, broken.Counters.Issues(), clean.Counters.Issues()) + + var findings []Record + for _, record := range broken.Records { + if record.Verdict.IsIssue() { + findings = append(findings, record) + } + } + require.NotEmpty(t, findings) + for _, finding := range findings { + assert.NotEmpty(t, finding.Reason, "a finding with no reason tells the user nothing") + } +} + +func TestAnalyzeHandlesBacnet(t *testing.T) { + pinGlobals(t) + path := filepath.Join(t.TempDir(), "bacnet.pcap") + session := pcapfixture.BacnetSession() + require.NoError(t, pcapfixture.WriteBacnet(path, session)) + + result := Analyze(t.Context(), Request{Protocol: protocol.BacnetIP, PcapFile: path, NoFilter: true}, Sink{}) + + require.NoError(t, result.Err) + assert.Len(t, result.Records, len(session)) + assert.Zero(t, result.Counters.Issues(), "the BACnet fixture round-trips, so it must produce no findings") +} + +// TestAnalyzeDrivesEveryPhase pins the breadcrumb the run panel draws. Deleting a phase would +// leave the panel showing a stage the run never reaches. +func TestAnalyzeDrivesEveryPhase(t *testing.T) { + pinGlobals(t) + request := writeCBus(t, pcapfixture.CBusSession()) + reporter := &recordingReporter{} + + result := Analyze(t.Context(), request, Sink{Progress: reporter}) + require.NoError(t, result.Err) + + recorded := reporter.snapshot() + assert.Equal(t, Phases, recorded.phases, "the run panel's breadcrumb is these phases, in this order") + assert.Positive(t, recorded.advanced, "a bar that never advances is a bar that says nothing") + assert.Positive(t, recorded.doneCalls, "the reporter must be finished, not abandoned") + require.Len(t, recorded.totals, len(Phases)) + assert.Equal(t, len(pcapfixture.CBusSession()), recorded.totals[len(recorded.totals)-1], + "the analyze phase must announce how many packets it is about to walk") +} + +// TestAnalyzeObservesEveryRecord pins the streaming that fills the table while a run is in +// flight. Without it the table would stay empty until the very end of a long capture. +func TestAnalyzeObservesEveryRecord(t *testing.T) { + pinGlobals(t) + request := writeCBus(t, pcapfixture.CBusSession()) + + var mu sync.Mutex + var observed []Record + result := Analyze(t.Context(), request, Sink{Observe: func(record Record) { + mu.Lock() + defer mu.Unlock() + observed = append(observed, record) + }}) + + require.NoError(t, result.Err) + mu.Lock() + defer mu.Unlock() + assert.Equal(t, result.Records, observed, "what was streamed must be exactly what was returned, in order") +} + +// TestAnalyzeStopsWhenTheContextIsCancelled is what makes the esc-abort binding real. +func TestAnalyzeStopsWhenTheContextIsCancelled(t *testing.T) { + pinGlobals(t) + request := writeCBus(t, pcapfixture.CBusSessionWithFailures()) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + result := Analyze(ctx, request, Sink{}) + + assert.True(t, result.Aborted, "a cancelled run must say it was cancelled rather than look complete") + assert.NoError(t, result.Err, "an abort is not a failure") +} + +// TestAnalyzePacketLimitZeroMeansUnlimited is the fix for a measured defect: the analyzer reads +// a limit of zero as "stop before the first packet", and the ui subcommand never parses the +// analyze flags that would set it to MaxUint, so the old UI's analyze command reported success +// having walked nothing. +func TestAnalyzePacketLimitZeroMeansUnlimited(t *testing.T) { + pinGlobals(t) + request := writeCBus(t, pcapfixture.CBusSession()) + request.PacketLimit = 0 + + result := Analyze(t.Context(), request, Sink{}) + + require.NoError(t, result.Err) + assert.Len(t, result.Records, len(pcapfixture.CBusSession())) +} + +func TestAnalyzePacketLimitCutsTheRunShort(t *testing.T) { + pinGlobals(t) + request := writeCBus(t, pcapfixture.CBusSession()) + request.PacketLimit = 3 + + result := Analyze(t.Context(), request, Sink{}) + + require.NoError(t, result.Err) + assert.Len(t, result.Records, 3) +} + +func TestAnalyzeOnlyParseSkipsTheComparison(t *testing.T) { + pinGlobals(t) + request := writeCBus(t, pcapfixture.CBusSession()) + request.OnlyParse = true + + result := Analyze(t.Context(), request, Sink{}) + + require.NoError(t, result.Err) + for _, record := range result.Records { + assert.Nil(t, record.Reserialized, "packet %d was only parsed, so nothing was written back", record.Number) + } +} + +func TestAnalyzeReportsAMissingCapture(t *testing.T) { + pinGlobals(t) + result := Analyze(t.Context(), Request{ + Protocol: protocol.CBus, + PcapFile: filepath.Join(t.TempDir(), "absent.pcap"), + }, Sink{}) + + require.Error(t, result.Err) + assert.Contains(t, result.Err.Error(), "absent.pcap") + assert.Empty(t, result.Records) +} + +func TestAnalyzeMeasuresElapsedWithTheInjectedClock(t *testing.T) { + pinGlobals(t) + request := writeCBus(t, pcapfixture.CBusSession()) + + var calls int + result := Analyze(t.Context(), request, Sink{Clock: func() time.Time { + calls++ + return time.Unix(0, 0).Add(time.Duration(calls) * time.Second) + }}) + + require.NoError(t, result.Err) + assert.Equal(t, time.Second, result.Elapsed, "elapsed must come from the injected clock, not the wall clock") +} + +func TestRequestLabelIsTheCommandThatWouldReproduceIt(t *testing.T) { + request := Request{Protocol: protocol.CBus, PcapFile: "/tmp/captures/plant.pcap"} + assert.Equal(t, "analyze c-bus plant.pcap", request.Label()) +} + +func TestFirstDifference(t *testing.T) { + for name, testCase := range map[string]struct { + a, b []byte + want int + }{ + "identical": {[]byte("abc"), []byte("abc"), -1}, + "both empty": {nil, nil, -1}, + "differs at start": {[]byte("abc"), []byte("xbc"), 0}, + "differs in middle": {[]byte("abcd"), []byte("abxd"), 2}, + "a is a prefix": {[]byte("ab"), []byte("abc"), 2}, + "b is a prefix": {[]byte("abc"), []byte("ab"), 2}, + "one empty": {[]byte{}, []byte("a"), 0}, + } { + t.Run(name, func(t *testing.T) { + assert.Equal(t, testCase.want, FirstDifference(testCase.a, testCase.b)) + }) + } +} + +func TestDifferingBytesCountsTheTailOfTheLongerSlice(t *testing.T) { + assert.Equal(t, 0, differingBytes([]byte("abc"), []byte("abc"))) + assert.Equal(t, 1, differingBytes([]byte("abc"), []byte("abx"))) + assert.Equal(t, 2, differingBytes([]byte("abc"), []byte("a"))) + assert.Equal(t, 3, differingBytes([]byte("abc"), []byte("xyz"))) +} + +// TestHexRowCaretColumnPointsAtTheRightByte is what makes the detail pane's marker truthful. +func TestHexRowCaretColumnPointsAtTheRightByte(t *testing.T) { + data := []byte{0x5c, 0x33, 0x31, 0x33, 0x38, 0x30, 0x30, 0x30} + row := hexRow(data, 0, len(data), 8) + + for offset := range len(data) { + column := hexRowCaretColumn(offset, 8) + assert.Equal(t, hexOf(data[offset]), row[column:column+2], + "the caret for offset %d must land on that byte's two hex digits", offset) + } +} + +// hexOf renders one byte as its two lowercase hex digits. +func hexOf(b byte) string { + const digits = "0123456789abcdef" + return string([]byte{digits[b>>4], digits[b&0xf]}) +} + +func TestHexDumpLinesRendersOffsetsAndPrintables(t *testing.T) { + lines := HexDumpLines([]byte("hello world"), 8, 10) + require.Len(t, lines, 2) + assert.True(t, strings.HasPrefix(lines[0], "00000000 "), "got %q", lines[0]) + assert.Contains(t, lines[0], "|hello wo|") + assert.True(t, strings.HasPrefix(lines[1], "00000008 "), "got %q", lines[1]) + assert.Contains(t, lines[1], "|rld|") +} + +func TestHexDumpLinesSaysWhenThereIsNothing(t *testing.T) { + assert.Equal(t, []string{"(empty)"}, HexDumpLines(nil, 8, 4)) +} + +func TestPreviewQuotesAndTruncates(t *testing.T) { + assert.Equal(t, `""`, Preview(nil, 20, ".")) + assert.Equal(t, `"~~~\r"`, Preview([]byte("~~~\r"), 20, ".")) + assert.Equal(t, `"abcd.`, Preview([]byte("abcdefghij"), 6, ".")) +} + +func TestMessageNameStripsThePointerAndTheGeneratedPrefix(t *testing.T) { + pinGlobals(t) + result := Analyze(t.Context(), writeCBus(t, pcapfixture.CBusSession()), Sink{}) + require.NoError(t, result.Err) + + var named int + for _, record := range result.Records { + if record.Summary == "" { + continue + } + named++ + assert.NotContains(t, record.Summary, "*", "%q must not carry the pointer marker", record.Summary) + assert.NotContains(t, record.Summary, ".", "%q must be the bare type name", record.Summary) + assert.False(t, strings.HasPrefix(record.Summary, "_"), "%q must not carry the generated-type prefix", record.Summary) + } + assert.Positive(t, named, "the fixture parses, so at least one record must be named") +} + +func TestDirectionFollowsTheConfiguredClient(t *testing.T) { + pinGlobals(t) + result := Analyze(t.Context(), writeCBus(t, pcapfixture.CBusSession()), Sink{}) + require.NoError(t, result.Err) + + var requests, responses int + for _, record := range result.Records { + switch record.Direction { + case DirectionRequest: + requests++ + case DirectionResponse: + responses++ + } + } + assert.Positive(t, requests, "the fixture interleaves both directions") + assert.Positive(t, responses) +} + +func TestDirectionIsUnknownWithoutAClient(t *testing.T) { + pinGlobals(t) + request := writeCBus(t, pcapfixture.CBusSession()) + request.Client = "" + + result := Analyze(t.Context(), request, Sink{}) + + require.NoError(t, result.Err) + for _, record := range result.Records { + assert.Equal(t, DirectionUnknown, record.Direction, + "with no client configured the tool must not guess which way a packet went") + } +} + +func TestVerdictIsIssueOnlyForDefects(t *testing.T) { + for verdict, want := range map[Verdict]bool{ + VerdictOK: false, + VerdictSkipped: false, + VerdictFiltered: false, + VerdictNoPayload: false, + VerdictBytesDiffer: true, + VerdictParseFail: true, + VerdictSerializeFail: true, + } { + assert.Equal(t, want, verdict.IsIssue(), "verdict %s", verdict) + } +} diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/appearance_test.go b/plc4go/tools/plc4xpcapanalyzer/ui/appearance_test.go new file mode 100644 index 000000000..76c6d0a21 --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/ui/appearance_test.go @@ -0,0 +1,253 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package ui + +import ( + "strings" + "testing" + + "charm.land/lipgloss/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/tui" + "github.com/apache/plc4x-extras/plc4go/tools/internal/tuitest" +) + +// The rest of the suite renders with the plain theme, because assertions read better against +// text than against escape sequences. These are the exception: the coloured themes are what a +// user actually sees, and a band that stops short of the edge or a style whose reset punches a +// hole in one is invisible in plain text. + +// TestBandedRowsCoverTheFullWidth pins the top bar and the command bar as bars: each has to be +// painted across its whole width, at every width the status line degrades through. +func TestBandedRowsCoverTheFullWidth(t *testing.T) { + state, _ := demoState(t) + + for _, dark := range []bool{true, false} { + for _, width := range []int{157, 120, 100, 80, 64} { + m := newTestModelWithTheme(t, state, tui.Size{Width: width, Height: 40}, + tui.Options{Dark: dark}) + rows := strings.Split(m.Render(), "\n") + require.Len(t, rows, 40) + + for _, row := range []struct { + name string + index int + }{ + {"status bar", 0}, + {"command bar", len(rows) - 2}, + } { + line := rows[row.index] + assert.Equal(t, width, lipgloss.Width(line), + "%s at %d columns should be exactly the screen width", row.name, width) + assert.Equal(t, width, tuitest.PaintedCells(line), + "%s at %d columns should be painted across its whole width", row.name, width) + } + } + } +} + +// TestNoColorRenderIsFreeOfColour is the NO_COLOR contract for the whole screen. A band is a +// background, and a background is the one thing that cannot degrade gracefully: it prints as a +// solid block on a terminal that was asked for no colour. +func TestNoColorRenderIsFreeOfColour(t *testing.T) { + state, _ := demoState(t) + m := newTestModelWithTheme(t, state, tui.Size{Width: 120, Height: 40}, + tui.Options{Dark: true, NoColor: true, ASCII: true}) + render := m.Render() + + assert.Empty(t, tuitest.ColourCodes(render), "a NO_COLOR render must set no colour") + assert.Equal(t, 0, tuitest.PaintedCells(strings.Split(render, "\n")[0]), + "a NO_COLOR status bar must not be banded") +} + +// TestColouredRenderKeepsEveryRowExactlyTheScreenWidth guards the whole screen rather than the +// bands: a row that miscounts its cells because of an escape sequence tears every row below it. +func TestColouredRenderKeepsEveryRowExactlyTheScreenWidth(t *testing.T) { + state, _ := demoState(t) + for _, size := range []tui.Size{{Width: 157, Height: 50}, {Width: 100, Height: 30}, {Width: 80, Height: 24}} { + m := newTestModelWithTheme(t, state, size, tui.Options{Dark: true}) + rows := strings.Split(m.Render(), "\n") + assert.Len(t, rows, size.Height) + for i, row := range rows { + assert.Equal(t, size.Width, lipgloss.Width(row), + "row %d at %dx%d", i, size.Width, size.Height) + } + } +} + +// --- colour that carries meaning --- + +// TestTheVerdictColumnIsColouredByWhatItMeans is the point of the column: a defect is red, a +// clean round trip green, and a skip neither, because a skip is not a finding and must not read +// as though it were. +func TestTheVerdictColumnIsColouredByWhatItMeans(t *testing.T) { + theme := tui.NewTheme(tui.Options{Dark: true}) + state, _ := demoState(t) + m := newTestModelWithTheme(t, state, wide, tui.Options{Dark: true}) + + for verdict, want := range map[Verdict]string{ + VerdictOK: theme.Ok.Render("x"), + VerdictParseFail: theme.Err.Render("x"), + VerdictSerializeFail: theme.Err.Render("x"), + VerdictBytesDiffer: theme.Err.Render("x"), + VerdictSkipped: theme.Muted.Render("x"), + VerdictFiltered: theme.Muted.Render("x"), + VerdictNoPayload: theme.Muted.Render("x"), + } { + assert.Equal(t, want, m.verdictStyle(verdict).Render("x"), verdict.String()) + } + + // And the three classes are actually distinguishable from one another. + ok := m.verdictStyle(VerdictOK).Render("x") + issue := m.verdictStyle(VerdictParseFail).Render("x") + quiet := m.verdictStyle(VerdictSkipped).Render("x") + assert.NotEqual(t, ok, issue) + assert.NotEqual(t, ok, quiet) + assert.NotEqual(t, issue, quiet) +} + +// TestAPacketRowIsStyledPerCell guards the defect that made the browser's selected row +// invisible: a terminal ends a style at the next reset, so a row styled as a whole loses its +// styling at the first cell that colours itself. +func TestAPacketRowIsStyledPerCell(t *testing.T) { + theme := tui.NewTheme(tui.Options{Dark: true}) + m := newTestModelWithTheme(t, mustState(t), wide, tui.Options{Dark: true}) + columns := packetLayout(wide.Width) + + row := m.renderPacketRow(okRecord(1), true, columns, wide.Width) + plain := tuitest.Strip(row) + + // The selection reaches the message column, which sits after the cells that colour + // themselves. Styling the row as a whole would have ended it at the first of them. + selection := tuitest.Escapes.FindString(theme.SelectedRow.Render("x")) + require.NotEmpty(t, selection) + message := strings.Index(plain, "CBusMessageToServer") + require.Positive(t, message, "the fixture has to have a message to look for") + assert.Contains(t, styleAt(row, message), selection, + "the selection has to still be in force at the message column") + + // The verdict keeps its own colour even on the selected row: it is the answer the tool + // exists to give. + verdict := strings.Index(plain, "ok") + require.Positive(t, verdict) + assert.Contains(t, styleAt(row, verdict), tuitest.Escapes.FindString(theme.Ok.Render("x")), + "a clean round trip stays green on the selected row") +} + +// TestARowIsExactlyTheWidthWhateverItsColours is the arithmetic that styling must not disturb. +func TestARowIsExactlyTheWidthWhateverItsColours(t *testing.T) { + m := newTestModelWithTheme(t, mustState(t), wide, tui.Options{Dark: true}) + for _, width := range []int{150, 120, 90, 70, 50} { + columns := packetLayout(width) + for _, record := range []Record{okRecord(1), brokenRecord(2)} { + for _, selected := range []bool{true, false} { + row := m.renderPacketRow(record, selected, columns, width) + assert.Equal(t, width, lipgloss.Width(row), + "%s selected=%v at %d columns", record.Verdict, selected, width) + } + } + } +} + +// TestAHexLineSeparatesTheBytesFromTheScaffolding checks the dump reads as data rather than as +// a wall: where you are and what it says as text are context, and only the middle is the data. +func TestAHexLineSeparatesTheBytesFromTheScaffolding(t *testing.T) { + m := newTestModelWithTheme(t, mustState(t), wide, tui.Options{Dark: true}) + line := HexDumpLines([]byte("322100AD\r\n"), hexBytesPerRow, 4)[0] + rendered := m.renderHexLine(line) + + assert.Equal(t, line, tuitest.Strip(rendered), "styling must not change a single character") + + plain := tuitest.Strip(rendered) + offset := styleAt(rendered, strings.Index(plain, "00000000")) + data := styleAt(rendered, strings.Index(plain, "33 32")) + text := styleAt(rendered, strings.Index(plain, "|")) + + assert.NotEqual(t, offset, data, "the offset should not read as loudly as the bytes") + assert.NotEqual(t, text, data, "nor should the text pane") + assert.Equal(t, offset, text, "the two kinds of context should read alike") +} + +// TestATreeLineDimsItsFrames is what makes plc4x's parse trees readable rather than textural: +// undifferentiated, the boxes outweigh what is inside them. +func TestATreeLineDimsItsFrames(t *testing.T) { + m := newTestModelWithTheme(t, mustState(t), wide, tui.Options{Dark: true}) + line := "╔═CBusMessage/request═╗" + rendered := m.renderTreeLine(line) + + assert.Equal(t, line, tuitest.Strip(rendered), "styling must not change a single character") + + plain := tuitest.Strip(rendered) + frame := styleAt(rendered, strings.Index(plain, "╔")) + content := styleAt(rendered, strings.Index(plain, "CBusMessage")) + assert.NotEqual(t, frame, content, "the frame and its contents must not read alike") + + // A line with no frame at all is left entirely at full contrast. + assert.Equal(t, content, styleAt(m.renderTreeLine("0x7e 126 RESET"), 0)) +} + +// TestOnlyRealBoxDrawingIsDimmed is the deliberate limit: the ASCII characters a box could be +// drawn from occur in payload text too, and dimming those would dim data. +func TestOnlyRealBoxDrawingIsDimmed(t *testing.T) { + for _, r := range []rune{'═', '║', '╔', '╝', '┄', '┆', '─', '│'} { + assert.True(t, isBoxDrawing(r), "%c is box drawing", r) + } + for _, r := range []rune{'-', '|', '+', '~', '0', 'x', '/', ' ', 'A'} { + assert.False(t, isBoxDrawing(r), "%c is not box drawing and may carry data", r) + } +} + +// styleAt returns the styling in force at a cell offset of a rendered line, as the terminal +// would have it: a style holds until something replaces or resets it. +func styleAt(rendered string, cell int) string { + if cell < 0 { + return "" + } + current, seen, rest := "", 0, rendered + for rest != "" { + if location := tuitest.Escapes.FindStringIndex(rest); location != nil && location[0] == 0 { + current = rest[:location[1]] + rest = rest[location[1]:] + continue + } + next := len(rest) + if location := tuitest.Escapes.FindStringIndex(rest); location != nil { + next = location[0] + } + for _, r := range rest[:next] { + if seen == cell { + return current + } + seen++ + _ = r + } + rest = rest[next:] + } + return current +} + +// mustState is a demo session, for the renderers that only need a model to hang off. +func mustState(t *testing.T) *State { + t.Helper() + state, _ := demoState(t) + return state +} diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/commands.go b/plc4go/tools/plc4xpcapanalyzer/ui/commands.go index f7f206459..5008a6d59 100644 --- a/plc4go/tools/plc4xpcapanalyzer/ui/commands.go +++ b/plc4go/tools/plc4xpcapanalyzer/ui/commands.go @@ -23,704 +23,962 @@ import ( "context" "fmt" "os" - "path" + "path/filepath" "reflect" - "runtime/debug" + "sort" + "strconv" "strings" - "time" - cliConfig "github.com/apache/plc4x/plc4go-extras/tools/plc4xpcapanalyzer/config" - "github.com/apache/plc4x/plc4go-extras/tools/plc4xpcapanalyzer/internal/analyzer" - "github.com/apache/plc4x/plc4go-extras/tools/plc4xpcapanalyzer/internal/extractor" plc4xconfig "github.com/apache/plc4x/plc4go/pkg/api/config" - "github.com/apache/plc4x/plc4go/spi" - - "github.com/pkg/errors" - "github.com/rivo/tview" + "github.com/apache/plc4x/plc4go/spi/errors" "github.com/rs/zerolog" - "github.com/rs/zerolog/log" + + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol" ) -const rootCommandIndicator = "rootCommand" - -var rootCommand = Command{ - Name: rootCommandIndicator, - subCommands: []Command{ - { - Name: "ls", - Description: "list directories", - action: func(_ context.Context, _ Command, dir string) error { - if dir == "" { - dir = currentDir - } - _, _ = fmt.Fprintf(commandOutput, "dir cotents of %s\n", dir) - readDir, err := os.ReadDir(dir) - if err != nil { - return err +// The command layer. +// +// Every command is a function of the session state that RETURNS what it did. Nothing here +// writes into a widget, formats an escape code or knows that a terminal exists — which is the +// whole difference from the version this replaces, where each action printed markup straight +// into a tview.TextView through a package-level io.Writer, and so could be neither tested nor +// re-rendered when the terminal resized. +// +// Commands run on the Bubble Tea event loop, because they mutate State and State belongs to +// the model. The two commands that can take a long time — analyze and extract — do not do the +// work themselves: they return a Request in their Outcome and the model starts it as a +// tea.Cmd. + +// Outcome is what running a command produced. +// +// It is a record of effects rather than a set of callbacks so that a test can assert on it +// directly, and so that the model stays the only place that decides what an effect looks like +// on screen. +type Outcome struct { + // Lines is the text the command produced, for the log pane. + Lines []string + // Err is set when the command failed. A failed command still produces its Lines. + Err error + + // Quit asks the program to exit. + Quit bool + // Abort asks for the running analysis to be cancelled. + Abort bool + // ClearPackets, ClearLog and ClearTranscript ask for the corresponding pane to be emptied. + ClearPackets bool + ClearLog bool + ClearTranscript bool + // Analysis and Extraction ask for a long-running run to be started. + Analysis *Request + Extraction *Request +} + +// Failed reports whether the command failed. +func (o Outcome) Failed() bool { return o.Err != nil } + +// say builds an outcome carrying text. +func say(lines ...string) Outcome { return Outcome{Lines: lines} } + +// fail builds a failed outcome. +func fail(err error) Outcome { return Outcome{Err: err} } + +// failf builds a failed outcome from a format string. +func failf(format string, args ...any) Outcome { return Outcome{Err: errors.Errorf(format, args...)} } + +// Command is one node of the command tree. A node with a Run is executable; a node with Sub +// dispatches to its children; a node may have both, as host and conf do not but clear does. +type Command struct { + Name string + Description string + // Run performs the command. arg is everything after the command's own name. + Run func(ctx context.Context, state *State, arg string) Outcome + // Sub are the subcommands. + Sub []Command + // Suggest offers completions for the argument, given what has been typed of it so far. + // The returned values are argument fragments; the caller prefixes the command path. + Suggest func(state *State, partial string) []string +} + +// child finds the subcommand with this exact name. +func (c Command) child(name string) (Command, bool) { + for _, sub := range c.Sub { + if sub.Name == name { + return sub, true + } + } + return Command{}, false +} + +// resolve walks the tree for a command line and returns the node to run together with its +// argument. A word is only treated as a subcommand name once it is finished — followed by a +// space or by more words — so that completing a half-typed name still sees the partial word. +func (c Command) resolve(line string) (Command, string, bool) { + node := c + rest := line + matchedAny := false + for { + trimmed := strings.TrimLeft(rest, " ") + if trimmed == "" { + break + } + name, remainder, _ := strings.Cut(trimmed, " ") + child, ok := node.child(name) + if !ok { + break + } + node = child + rest = remainder + matchedAny = true + } + if !matchedAny { + return Command{}, "", false + } + return node, strings.TrimSpace(rest), true +} + +// Execute runs a command line against the session state. +func (c Command) Execute(ctx context.Context, state *State, line string) Outcome { + line = strings.TrimSpace(line) + if line == "" { + return Outcome{} + } + node, arg, ok := c.resolve(line) + if !ok { + word, _, _ := strings.Cut(line, " ") + return failf("unknown command %q, try help", word) + } + if node.Run == nil { + return failf("%s needs one of: %s", node.Name, strings.Join(node.childNames(), ", ")) + } + return node.Run(ctx, state, arg) +} + +// childNames lists the subcommand names, for an error message. +func (c Command) childNames() []string { + names := make([]string, 0, len(c.Sub)) + for _, sub := range c.Sub { + names = append(names, sub.Name) + } + return names +} + +// Completions returns whole replacement lines for what has been typed so far. +// +// Whole lines, not fragments, because that is what bubbles/textinput matches: it tests each +// candidate against the ENTIRE field value as a case-insensitive prefix, so returning "c-bus" +// for the line "analyze c-" would match nothing. +// +// An empty line returns nothing. Offering all twenty-odd commands the moment the prompt is +// focused fills the bottom of the screen with a menu the user did not ask for. +func (c Command) Completions(state *State, line string) []string { + if strings.TrimSpace(line) == "" { + return nil + } + + node := c + var consumed strings.Builder + rest := line + for { + trimmed := strings.TrimLeft(rest, " ") + if trimmed == "" { + break + } + name, remainder, hasMore := strings.Cut(trimmed, " ") + child, ok := node.child(name) + if !ok { + break + } + // The last word is still being typed unless something follows it. Consuming it would + // complete against the wrong node: "log" would offer log's subcommands rather than + // finishing the word "log" itself. + if !hasMore { + break + } + node = child + consumed.WriteString(name) + consumed.WriteString(" ") + rest = remainder + } + prefix := consumed.String() + partial := strings.TrimLeft(rest, " ") + + var candidates []string + firstWord, _, _ := strings.Cut(partial, " ") + for _, sub := range node.Sub { + if strings.HasPrefix(sub.Name, firstWord) && !strings.Contains(partial, " ") { + candidates = append(candidates, prefix+sub.Name) + } + } + if node.Suggest != nil { + for _, suggestion := range node.Suggest(state, partial) { + candidates = append(candidates, prefix+suggestion) + } + } + return candidates +} + +// Walk visits every command in the tree, depth first, carrying the indentation depth. +func (c Command) Walk(depth int, visit func(depth int, command Command)) { + visit(depth, c) + for _, sub := range c.Sub { + sub.Walk(depth+1, visit) + } +} + +// Root builds the command tree. +// +// It is a function rather than a package variable because part of the tree is derived by +// reflection from the CLI configuration singletons, and a package variable would freeze that +// at import time — which is also what made the old tree impossible to exercise from a test. +func Root() Command { + root := Command{ + Name: "", + Description: "plc4xpcapanalyzer commands", + Sub: []Command{ + commandLs(), + commandCd(), + commandPwd(), + commandOpen(), + commandAnalyze(), + commandExtract(), + commandHost(), + commandRegister(), + commandUnregister(), + commandQuit(), + commandLog(), + commandConf(), + commandPlc4xConf(), + commandHistory(), + commandClear(), + commandAbort(), + }, + } + // help closes over the finished tree, so it has to be appended after it is built. + root.Sub = append(root.Sub, Command{ + Name: "help", + Description: "prints out this help", + Run: func(_ context.Context, _ *State, _ string) Outcome { + lines := []string{"Available commands"} + root.Walk(0, func(depth int, command Command) { + if command.Name == "" { + return } - for _, dirEntry := range readDir { - isDir := dirEntry.IsDir() - name := dirEntry.Name() - name = strings.TrimPrefix(name, dir) - if isDir { - name = fmt.Sprintf("[#0000ff]%s[white]", name) - } else if strings.HasSuffix(name, ".pcap") || strings.HasSuffix(name, ".pcapng") { - name = fmt.Sprintf("[#00ff00]%s[white]", name) - } - _, _ = fmt.Fprintf(commandOutput, "%s\n", name) + description := command.Description + if description == "" { + description = command.Name + "s" } - return nil - }, - // TODO: add parameter suggestions + lines = append(lines, strings.Repeat(" ", depth-1)+" "+command.Name+": "+description) + }) + return say(lines...) }, - { - Name: "cd", - Description: "changes directory", - action: func(_ context.Context, _ Command, newDir string) error { - var proposedCurrentDir string - if newDir == "" { - var err error - proposedCurrentDir, err = os.UserHomeDir() - if err != nil { - return err - } - } else if strings.HasPrefix(newDir, "."+string(os.PathSeparator)) { - proposedCurrentDir = currentDir + strings.TrimPrefix(newDir, ".") - } else if strings.HasPrefix(newDir, ""+string(os.PathSeparator)) { - proposedCurrentDir = newDir - } else { - proposedCurrentDir = currentDir + string(os.PathSeparator) + newDir + }) + return root +} + +func commandLs() Command { + return Command{ + Name: "ls", + Description: "list directories", + Run: func(_ context.Context, state *State, dir string) Outcome { + if dir == "" { + dir = state.CurrentDir + } else if !filepath.IsAbs(dir) { + dir = filepath.Join(state.CurrentDir, dir) + } + entries, err := os.ReadDir(dir) + if err != nil { + return fail(errors.Wrapf(err, "error listing %s", dir)) + } + lines := []string{"contents of " + dir} + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() { + name += string(os.PathSeparator) } - stat, err := os.Stat(proposedCurrentDir) + lines = append(lines, " "+name) + } + if len(entries) == 0 { + lines = append(lines, " (empty)") + } + return say(lines...) + }, + Suggest: suggestDirectories, + } +} + +func commandCd() Command { + return Command{ + Name: "cd", + Description: "changes directory", + Run: func(_ context.Context, state *State, newDir string) Outcome { + var proposed string + switch { + case newDir == "": + home, err := os.UserHomeDir() if err != nil { - return err - } - if !stat.IsDir() { - return errors.Errorf("%s is not a dir", newDir) - } - currentDir = proposedCurrentDir - _, _ = fmt.Fprintf(commandOutput, "current directory: %s\n", currentDir) - return nil - }, - parameterSuggestions: func(currentText string) (entries []string) { - if strings.HasPrefix(currentText, string(os.PathSeparator)) { - dirEntries, err := os.ReadDir(currentText) - if err != nil { - plc4xpcapanalyzerLog.Warn().Err(err).Msg("Error suggesting directories") - return - } - for _, dirEntry := range dirEntries { - entry := path.Join(currentText, dirEntry.Name()) - entries = append(entries, entry) - } - } else { - dirEntries, err := os.ReadDir(currentDir) - if err != nil { - plc4xpcapanalyzerLog.Warn().Err(err).Msg("Error suggesting directories") - return - } - for _, dirEntry := range dirEntries { - entry := path.Join(".", dirEntry.Name()) - entries = append(entries, entry) - } + return fail(errors.Wrap(err, "error resolving the home directory")) } - return - }, + proposed = home + case filepath.IsAbs(newDir): + proposed = newDir + default: + proposed = filepath.Join(state.CurrentDir, newDir) + } + stat, err := os.Stat(proposed) + if err != nil { + return fail(errors.Wrapf(err, "error changing to %s", proposed)) + } + if !stat.IsDir() { + return failf("%s is not a directory", newDir) + } + state.CurrentDir = filepath.Clean(proposed) + return say("current directory: " + state.CurrentDir) }, - { - Name: "pwd", - Description: "shows current directory", - action: func(_ context.Context, _ Command, _ string) error { - _, _ = fmt.Fprintf(commandOutput, "current directory: %s\n", currentDir) - return nil - }, + Suggest: suggestDirectories, + } +} + +func commandPwd() Command { + return Command{ + Name: "pwd", + Description: "shows current directory", + Run: func(_ context.Context, state *State, _ string) Outcome { + return say("current directory: " + state.CurrentDir) }, - { - Name: "open", - Description: "open file", - action: func(_ context.Context, _ Command, pcapFile string) error { - return OpenFile(pcapFile) - }, - parameterSuggestions: func(currentText string) (entries []string) { - entries = append(entries, config.History.Last10Files...) - readDir, err := os.ReadDir(currentDir) - if err != nil { - return - } - for _, dirEntry := range readDir { - name := dirEntry.Name() - name = strings.TrimPrefix(name, currentDir) - if strings.HasSuffix(dirEntry.Name(), ".cap") || strings.HasSuffix(dirEntry.Name(), ".pcap") || strings.HasSuffix(name, ".pcapng") { - entries = append(entries, name) - } - } - return - }, + } +} + +func commandOpen() Command { + return Command{ + Name: "open", + Description: "open a pcap file", + Run: func(_ context.Context, state *State, pcapFile string) Outcome { + capture, err := state.Open(pcapFile) + if err != nil { + return fail(err) + } + return say("opened " + capture.Path) }, - { - Name: "analyze", - Description: "Analyzes a pcap file using a driver", - action: func(ctx context.Context, _ Command, protocolTypeAndPcapFile string) error { - split := strings.Split(protocolTypeAndPcapFile, " ") - if len(split) != 2 { - return errors.Errorf("expect protocol and pcapfile") - } - protocolType := split[0] - pcapFile := strings.TrimPrefix(protocolTypeAndPcapFile, protocolType+" ") - cliConfig.PcapConfigInstance.Client = config.HostIp - cliConfig.RootConfigInstance.HideProgressBar = true - // disabled as we get this output anyway with the message call back - //cliConfig.RootConfigInstance.Verbosity = 4 - return analyzer.AnalyzeWithOutputAndCallback(ctx, pcapFile, protocolType, tview.ANSIWriter(messageOutput), tview.ANSIWriter(messageOutput), func(parsed spi.Message) { - spiNumberOfMessagesReceived++ - spiMessageReceived(spiNumberOfMessagesReceived, time.Now(), parsed) - }) - }, - parameterSuggestions: func(currentText string) (entries []string) { - for _, file := range loadedPcapFiles { - for _, protocol := range protocolList { - entries = append(entries, protocol+" "+file.path) - } - } - return - }, + Suggest: suggestCaptureFiles, + } +} + +func commandAnalyze() Command { + return Command{ + Name: "analyze", + Description: "analyzes a pcap file using a driver", + Run: func(_ context.Context, state *State, arg string) Outcome { + request, err := analysisRequest(state, arg) + if err != nil { + return fail(err) + } + return Outcome{Analysis: &request} }, - { - Name: "extract", - Description: "Extract a pcap file using a driver", - action: func(ctx context.Context, _ Command, protocolTypeAndPcapFile string) error { - split := strings.Split(protocolTypeAndPcapFile, " ") - if len(split) != 2 { - return errors.Errorf("expect protocol and pcapfile") - } - protocolType := split[0] - pcapFile := strings.TrimPrefix(protocolTypeAndPcapFile, protocolType+" ") - cliConfig.PcapConfigInstance.Client = config.HostIp - cliConfig.RootConfigInstance.HideProgressBar = true - cliConfig.RootConfigInstance.Verbosity = 4 - return extractor.ExtractWithOutput(ctx, pcapFile, protocolType, tview.ANSIWriter(messageOutput), tview.ANSIWriter(messageOutput)) - }, - parameterSuggestions: func(currentText string) (entries []string) { - for _, file := range loadedPcapFiles { - for _, protocol := range protocolList { - entries = append(entries, protocol+" "+file.path) - } - } - return - }, + Suggest: suggestProtocolAndFile, + } +} + +func commandExtract() Command { + return Command{ + Name: "extract", + Description: "extract a pcap file using a driver", + Run: func(_ context.Context, state *State, arg string) Outcome { + request, err := analysisRequest(state, arg) + if err != nil { + return fail(err) + } + return Outcome{Extraction: &request} }, - { - Name: "host", - Description: "The host which is assumed to be the sender (important for protocols that are directional)", - subCommands: []Command{ - { - Name: "set", - action: func(_ context.Context, _ Command, host string) error { - config.HostIp = host - return nil - }, + Suggest: suggestProtocolAndFile, + } +} + +// analysisRequest parses " " into a request, falling back to the session's +// current protocol and capture when either half is omitted. +// +// The fallback is what makes the sidebar and the "a" shortcut work: they run "analyze" with +// nothing after it, and the user has already said which capture is selected. +func analysisRequest(state *State, arg string) (Request, error) { + arg = strings.TrimSpace(arg) + if arg == "" { + return state.Request() + } + name, file, hasFile := strings.Cut(arg, " ") + proto, err := protocol.Resolve(name) + if err != nil { + return Request{}, err + } + if !hasFile || strings.TrimSpace(file) == "" { + capture, ok := state.CurrentCapture() + if !ok { + return Request{}, errors.Errorf("analyze %s needs a file: analyze %s ", name, name) + } + return state.RequestFor(proto, capture.Path), nil + } + file = strings.TrimSpace(file) + if !filepath.IsAbs(file) { + file = filepath.Join(state.CurrentDir, file) + } + if _, err := os.Stat(file); err != nil { + return Request{}, errors.Wrapf(err, "error reading %s", file) + } + return state.RequestFor(proto, file), nil +} + +func commandHost() Command { + return Command{ + Name: "host", + Description: "the host assumed to be the sender (matters for directional protocols)", + Sub: []Command{ + { + Name: "set", + Description: "sets the client address", + Run: func(_ context.Context, state *State, host string) Outcome { + host = strings.TrimSpace(host) + if host == "" { + return failf("host set needs an address: host set ") + } + state.HostIP = host + state.Config.HostIp = host + return say("client address: " + host) }, - { - Name: "get", - action: func(_ context.Context, _ Command, host string) error { - _, _ = fmt.Fprintf(commandOutput, "current set host %s", config.HostIp) - return nil - }, + }, + { + Name: "get", + Description: "shows the client address", + Run: func(_ context.Context, state *State, _ string) Outcome { + if state.HostIP == "" { + return say("no client address set: host set ") + } + return say("client address: " + state.HostIP) }, }, }, - { - Name: "register", - Description: "register a driver in the subsystem", - action: func(_ context.Context, _ Command, driver string) error { - return registerDriver(driver) - }, - parameterSuggestions: func(currentText string) (entries []string) { - for _, protocol := range protocolList { - if strings.HasPrefix(protocol, currentText) { - entries = append(entries, protocol) - } + } +} + +func commandRegister() Command { + return Command{ + Name: "register", + Description: "register a driver in the subsystem", + Run: func(_ context.Context, state *State, driver string) Outcome { + driver = strings.TrimSpace(driver) + if err := state.RegisterDriver(driver); err != nil { + return fail(err) + } + return say("registered driver " + driver) + }, + Suggest: suggestDrivers, + } +} + +// commandUnregister is new. The sidebar lists the drivers and lets the cursor act on a row, so +// there has to be something for the row of an already-registered driver to do; leaving it +// inert would be exactly the dead affordance this port set out to remove. +func commandUnregister() Command { + return Command{ + Name: "unregister", + Description: "forget a driver registered in the subsystem", + Run: func(_ context.Context, state *State, driver string) Outcome { + driver = strings.TrimSpace(driver) + if err := ValidateDriver(driver); err != nil { + return fail(err) + } + if !state.IsRegistered(driver) { + return failf("%s is not registered", driver) + } + remaining := make([]string, 0, len(state.Registered)) + for _, registered := range state.Registered { + if registered != driver { + remaining = append(remaining, registered) } - return - }, + } + // plc4x offers no way to remove a driver from a manager, so the manager is rebuilt + // from what is left. Saying so here is cheaper than surprising the next reader. + state.Registered, state.DriverManager = nil, nil + for _, registered := range remaining { + _ = state.RegisterDriver(registered) + } + return say("unregistered driver " + driver) }, - { - Name: "quit", - Description: "Quits the application", + Suggest: suggestDrivers, + } +} + +func commandQuit() Command { + return Command{ + Name: "quit", + Description: "quits the application", + Run: func(_ context.Context, _ *State, _ string) Outcome { + return Outcome{Quit: true} }, - { - Name: "log", - Description: "Log related operations", - subCommands: []Command{ - { - Name: "get", - Description: "Get a log level", - action: func(_ context.Context, _ Command, _ string) error { - _, _ = fmt.Fprintf(commandOutput, "Current log level %s", log.Logger.GetLevel()) - return nil - }, - }, - { - Name: "set", - Description: "Sets a log level", - action: func(_ context.Context, _ Command, level string) error { - parseLevel, err := zerolog.ParseLevel(level) - if err != nil { - return errors.Wrapf(err, "Error setting log level") - } - setLevel(parseLevel) - log.Logger = log.Logger.Level(parseLevel) - return nil - }, - parameterSuggestions: func(currentText string) (entries []string) { - levels := []string{ - zerolog.LevelTraceValue, - zerolog.LevelDebugValue, - zerolog.LevelInfoValue, - zerolog.LevelWarnValue, - zerolog.LevelErrorValue, - zerolog.LevelFatalValue, - zerolog.LevelPanicValue, - } - for _, level := range levels { - entries = append(entries, level) - } - return - }, + } +} + +func commandLog() Command { + return Command{ + Name: "log", + Description: "log related operations", + Sub: []Command{ + { + Name: "get", + Description: "get the log level", + Run: func(_ context.Context, state *State, _ string) Outcome { + return say("current log level " + state.LogLevel.String()) }, }, - }, - { - Name: "conf", - Description: "Various settings for plc4xpcapanalyzer", - subCommands: []Command{ - { - Name: "list", - Description: "list config values with their current settings", - action: func(_ context.Context, _ Command, _ string) error { - allCliConfigsValue := reflect.ValueOf(allCliConfigsInstances) - for i := 0; i < allCliConfigsValue.NumField(); i++ { - allConfigField := allCliConfigsValue.Field(i) - allConfigFieldType := allCliConfigsValue.Type().Field(i) - _, _ = fmt.Fprintf(commandOutput, "%s:\n", allConfigFieldType.Name) - configInstanceReflectValue := reflect.ValueOf(allConfigField.Interface()) - if configInstanceReflectValue.Kind() == reflect.Ptr { - configInstanceReflectValue = configInstanceReflectValue.Elem() - } - for j := 0; j < configInstanceReflectValue.NumField(); j++ { - configField := configInstanceReflectValue.Field(j) - configFieldType := configInstanceReflectValue.Type().Field(j) - if configFieldType.Tag.Get("json") == "-" { - // Ignore those - continue - } - _, _ = fmt.Fprintf(commandOutput, " %s: %s\t= %v\n", configFieldType.Name, configFieldType.Type, configField.Interface()) - } - } - return nil - }, + { + Name: "set", + Description: "sets the log level", + Run: func(_ context.Context, state *State, level string) Outcome { + parsed, err := zerolog.ParseLevel(strings.TrimSpace(level)) + if err != nil { + return fail(errors.Wrapf(err, "error setting log level")) + } + state.LogLevel = parsed + state.Config.LogLevel = parsed.String() + return say("log level " + parsed.String()) }, - { - Name: "set", - Description: "sets a config value", - subCommands: func() []Command { - var configCommand []Command - allCliConfigsValue := reflect.ValueOf(allCliConfigsInstances) - for i := 0; i < allCliConfigsValue.NumField(); i++ { - allConfigField := allCliConfigsValue.Field(i) - allConfigFieldType := allCliConfigsValue.Type().Field(i) - configCommand = append(configCommand, Command{ - Name: allConfigFieldType.Name, - Description: fmt.Sprintf("Setting for %s", allConfigFieldType.Name), - subCommands: func() []Command { - var configElementCommands []Command - configInstanceReflectValue := reflect.ValueOf(allConfigField.Interface()) - if configInstanceReflectValue.Kind() == reflect.Ptr { - configInstanceReflectValue = configInstanceReflectValue.Elem() - } - for i := 0; i < configInstanceReflectValue.NumField(); i++ { - field := configInstanceReflectValue.Field(i) - fieldOfType := configInstanceReflectValue.Type().Field(i) - if fieldOfType.Tag.Get("json") == "-" { - // Ignore those - continue - } - configElementCommands = append(configElementCommands, Command{ - Name: fieldOfType.Name, - Description: fmt.Sprintf("Sets value for %s", fieldOfType.Name), - action: func(_ context.Context, _ Command, argument string) error { - field.SetString(argument) - return nil - }, - }) - } - return configElementCommands - }(), - }) + Suggest: func(_ *State, partial string) []string { + var levels []string + for _, level := range logLevels { + if strings.HasPrefix(level, partial) { + levels = append(levels, level) } - return configCommand - }(), - }, - { - Name: "plc4xpcapanalyzer-debug", - Description: "Prints out debug information of the pcap analyzer itself", - subCommands: []Command{ - { - Name: "on", - Description: "debug on", - action: func(_ context.Context, _ Command, _ string) error { - plc4xpcapanalyzerLog = zerolog.New(zerolog.ConsoleWriter{Out: tview.ANSIWriter(consoleOutput)}) - return nil - }, - }, - { - Name: "off", - Description: "debug off", - action: func(_ context.Context, _ Command, _ string) error { - plc4xpcapanalyzerLog = zerolog.Nop() - return nil - }, - }, - }, - }, - { - Name: "auto-register", - Description: "autoregister driver at startup", - subCommands: []Command{ - { - Name: "list", - action: func(_ context.Context, currentCommand Command, argument string) error { - _, _ = fmt.Fprintf(commandOutput, "Auto-register enabled drivers:\n %s\n", strings.Join(config.AutoRegisterDrivers, "\n ")) - return nil - }, - }, - { - Name: "enable", - action: func(_ context.Context, _ Command, argument string) error { - return enableAutoRegister(argument) - }, - parameterSuggestions: func(currentText string) (entries []string) { - for _, protocol := range protocolList { - if strings.HasPrefix(protocol, currentText) { - entries = append(entries, protocol) - } - } - return - }, - }, - { - Name: "disable", - action: func(_ context.Context, _ Command, argument string) error { - return disableAutoRegister(argument) - }, - parameterSuggestions: func(currentText string) (entries []string) { - for _, protocol := range protocolList { - if strings.HasPrefix(protocol, currentText) { - entries = append(entries, protocol) - } - } - return - }, - }, - }, + } + return levels }, }, }, - { - Name: "plc4x-conf", - Description: "plc4x related settings", - subCommands: []Command{ - { - Name: "TraceTransactionManagerWorkers", - Description: "print information about transaction manager workers", - subCommands: []Command{ - { - Name: "on", - Description: "trace on", - action: func(_ context.Context, _ Command, _ string) error { - plc4xconfig.TraceTransactionManagerWorkers = true - return nil - }, - }, - { - Name: "off", - Description: "trace off", - action: func(_ context.Context, _ Command, _ string) error { - plc4xconfig.TraceTransactionManagerWorkers = false - return nil - }, - }, - }, + } +} + +// logLevels are the levels log set accepts. +var logLevels = []string{ + zerolog.LevelTraceValue, + zerolog.LevelDebugValue, + zerolog.LevelInfoValue, + zerolog.LevelWarnValue, + zerolog.LevelErrorValue, + zerolog.LevelFatalValue, + zerolog.LevelPanicValue, +} + +func commandConf() Command { + return Command{ + Name: "conf", + Description: "various settings for plc4xpcapanalyzer", + Sub: []Command{ + { + Name: "list", + Description: "list config values with their current settings", + Run: func(_ context.Context, _ *State, _ string) Outcome { + return say(describeConfigs(CliConfigInstances())...) }, - { - Name: "TraceTransactionManagerTransactions", - Description: "print information about transaction manager transactions", - subCommands: []Command{ - { - Name: "on", - Description: "trace on", - action: func(_ context.Context, _ Command, _ string) error { - plc4xconfig.TraceTransactionManagerTransactions = true - return nil - }, - }, - { - Name: "off", - Description: "trace off", - action: func(_ context.Context, _ Command, _ string) error { - plc4xconfig.TraceTransactionManagerTransactions = false - return nil - }, + }, + commandConfSet(), + { + Name: "plc4xpcapanalyzer-debug", + Description: "prints out debug information of the pcap analyzer itself", + Sub: []Command{ + { + Name: "on", + Description: "debug on", + Run: func(_ context.Context, state *State, _ string) Outcome { + state.Debug = true + return say("plc4xpcapanalyzer debug on") }, }, - }, - { - Name: "TraceDefaultMessageCodecWorker", - Description: "print information about message codec workers", - subCommands: []Command{ - { - Name: "on", - Description: "trace on", - action: func(_ context.Context, _ Command, _ string) error { - plc4xconfig.TraceDefaultMessageCodecWorker = true - return nil - }, - }, - { - Name: "off", - Description: "trace off", - action: func(_ context.Context, _ Command, _ string) error { - plc4xconfig.TraceDefaultMessageCodecWorker = false - return nil - }, + { + Name: "off", + Description: "debug off", + Run: func(_ context.Context, state *State, _ string) Outcome { + state.Debug = false + return say("plc4xpcapanalyzer debug off") }, }, }, }, + commandAutoRegister(), }, - { - Name: "history", - Description: "outputs the last commands", - action: func(_ context.Context, _ Command, _ string) error { - outputCommandHistory() - return nil + } +} + +func commandAutoRegister() Command { + return Command{ + Name: "auto-register", + Description: "autoregister drivers at startup", + Sub: []Command{ + { + Name: "list", + Description: "lists the drivers registered at startup", + Run: func(_ context.Context, state *State, _ string) Outcome { + if len(state.Config.AutoRegisterDrivers) == 0 { + return say("no drivers auto-registered: conf auto-register enable ") + } + lines := []string{"auto-register enabled drivers:"} + for _, driver := range state.Config.AutoRegisterDrivers { + lines = append(lines, " "+driver) + } + return say(lines...) + }, }, - }, - { - Name: "clear", - Description: "clear all outputs", - action: func(_ context.Context, _ Command, _ string) error { - messageOutputClear() - consoleOutputClear() - commandOutputClear() - return nil + { + Name: "enable", + Description: "auto-registers a driver at startup", + Run: func(_ context.Context, state *State, driver string) Outcome { + driver = strings.TrimSpace(driver) + if err := ValidateDriver(driver); err != nil { + return fail(err) + } + if err := state.Config.EnableAutoRegister(driver); err != nil { + return fail(err) + } + return say("auto-register enabled for " + driver) + }, + Suggest: suggestDrivers, }, - subCommands: []Command{ - { - Name: "message", - Description: "clears message output", - action: func(_ context.Context, _ Command, _ string) error { - messageOutputClear() - return nil - }, + { + Name: "disable", + Description: "stops auto-registering a driver at startup", + Run: func(_ context.Context, state *State, driver string) Outcome { + driver = strings.TrimSpace(driver) + if err := ValidateDriver(driver); err != nil { + return fail(err) + } + if err := state.Config.DisableAutoRegister(driver); err != nil { + return fail(err) + } + return say("auto-register disabled for " + driver) }, + Suggest: suggestDrivers, + }, + }, + } +} + +func commandPlc4xConf() Command { + toggle := func(name, description string, set func(bool)) Command { + return Command{ + Name: name, + Description: description, + Sub: []Command{ { - Name: "console", - Description: "clears console output", - action: func(_ context.Context, _ Command, _ string) error { - consoleOutputClear() - return nil + Name: "on", + Description: "trace on", + Run: func(_ context.Context, _ *State, _ string) Outcome { + set(true) + return say(name + " on") }, }, { - Name: "command", - Description: "clears command output", - action: func(_ context.Context, _ Command, _ string) error { - commandOutputClear() - return nil + Name: "off", + Description: "trace off", + Run: func(_ context.Context, _ *State, _ string) Outcome { + set(false) + return say(name + " off") }, }, }, + } + } + return Command{ + Name: "plc4x-conf", + Description: "plc4x related settings", + Sub: []Command{ + toggle("TraceTransactionManagerWorkers", "print information about transaction manager workers", + func(on bool) { plc4xconfig.TraceTransactionManagerWorkers = on }), + toggle("TraceTransactionManagerTransactions", "print information about transaction manager transactions", + func(on bool) { plc4xconfig.TraceTransactionManagerTransactions = on }), + toggle("TraceDefaultMessageCodecWorker", "print information about message codec workers", + func(on bool) { plc4xconfig.TraceDefaultMessageCodecWorker = on }), }, - { - Name: "abort", - Description: "abort currently running jobs", - action: func(_ context.Context, _ Command, _ string) error { - for _, cancelFunc := range cancelFunctions { - cancelFunc() - } - return nil - }, + } +} + +func commandHistory() Command { + return Command{ + Name: "history", + Description: "outputs the last commands", + Run: func(_ context.Context, state *State, _ string) Outcome { + if len(state.Config.History.Last10Commands) == 0 { + return say("no commands remembered yet") + } + lines := []string{"last commands"} + for i, command := range state.Config.History.Last10Commands { + lines = append(lines, " "+strconv.Itoa(i)+": "+command) + } + return say(lines...) }, - }, + } } -func init() { - // Because of the cycle we need to define the help command here as it needs access to the to command - rootCommand.subCommands = append(rootCommand.subCommands, Command{ - Name: "help", - Description: "prints out this help", - action: func(_ context.Context, _ Command, _ string) error { - _, _ = fmt.Fprintf(commandOutput, "[#0000ff]Available commands[white]\n") - rootCommand.visit(0, func(currentIndent int, command Command) { - indentString := strings.Repeat(" ", currentIndent) - description := command.Description - if description == "" { - description = command.Name + "s" - } - _, _ = fmt.Fprintf(commandOutput, "%s [#00ff00]%s[white]: %s\n", indentString, command.Name, description) - }) - return nil +func commandClear() Command { + return Command{ + Name: "clear", + Description: "clear all outputs", + Run: func(_ context.Context, _ *State, _ string) Outcome { + return Outcome{ClearPackets: true, ClearLog: true, ClearTranscript: true} }, - }) + Sub: []Command{ + { + Name: "message", + Description: "clears the analysed packets", + Run: func(_ context.Context, _ *State, _ string) Outcome { + return Outcome{ClearPackets: true} + }, + }, + { + Name: "console", + Description: "clears the log", + Run: func(_ context.Context, _ *State, _ string) Outcome { + return Outcome{ClearLog: true} + }, + }, + { + Name: "command", + Description: "clears the command transcript", + Run: func(_ context.Context, _ *State, _ string) Outcome { + return Outcome{ClearTranscript: true} + }, + }, + }, + } } -var NotDirectlyExecutable = errors.New("Not directly executable") +func commandAbort() Command { + return Command{ + Name: "abort", + Description: "abort currently running jobs", + Run: func(_ context.Context, _ *State, _ string) Outcome { + return Outcome{Abort: true} + }, + } +} -type Command struct { - Name string - Description string - action func(ctx context.Context, currentCommand Command, argument string) error - subCommands []Command - parameterSuggestions func(currentText string) (entries []string) -} - -func (c Command) Completions(currentCommandText string) (entries []string) { - if c.Name == rootCommandIndicator && len(currentCommandText) == 0 { - // We don't return anything here to not pollute the command text by default - return - } - if c.acceptsCurrentText(currentCommandText) { - currentCommandPrefix := c.currentCommandPrefix() - doesCommandTextTargetSubCommand := c.doesCommandTextTargetSubCommand(currentCommandPrefix) - if c.hasDirectExecution() && !doesCommandTextTargetSubCommand { - if c.parameterSuggestions != nil { - preparedForParameters := c.prepareForParameters(currentCommandText) - for _, parameterSuggestion := range c.parameterSuggestions(preparedForParameters) { - entries = append(entries, currentCommandPrefix+parameterSuggestion) - } - } else if currentCommandText == "" { - entries = append(entries, c.Name) - } +// commandConfSet builds "conf set " by reflecting over the CLI +// configuration singletons, the way the previous version did. +// +// Unlike the previous version it does not call reflect.Value.SetString unconditionally. Every +// non-string field — and most of them are bool or uint — made that call panic, and the panic +// was swallowed by a recover in the command dispatcher and reported as "panic occurred". Each +// kind is now converted properly and an unsupported one is a plain error. +func commandConfSet() Command { + set := Command{ + Name: "set", + Description: "sets a config value", + } + configs := reflect.ValueOf(CliConfigInstances()) + for i := range configs.NumField() { + configValue := configs.Field(i) + configType := configs.Type().Field(i) + fields := elemOf(reflect.ValueOf(configValue.Interface())) + if !fields.IsValid() || fields.Kind() != reflect.Struct { + continue } - if doesCommandTextTargetSubCommand { - remainder := c.prepareForSubCommand(currentCommandText) - for _, command := range c.subCommands { - for _, subCommandCompletions := range command.Completions(remainder) { - entries = append(entries, currentCommandPrefix+subCommandCompletions) - } + group := Command{ + Name: configType.Name, + Description: "setting for " + configType.Name, + } + for j := range fields.NumField() { + field := fields.Field(j) + fieldType := fields.Type().Field(j) + if fieldType.Tag.Get("json") == "-" || !field.CanSet() { + continue } + group.Sub = append(group.Sub, Command{ + Name: fieldType.Name, + Description: "sets " + configType.Name + "." + fieldType.Name, + Run: func(_ context.Context, _ *State, argument string) Outcome { + if err := assign(field, strings.TrimSpace(argument)); err != nil { + return fail(errors.Wrapf(err, "error setting %s.%s", configType.Name, fieldType.Name)) + } + return say(fmt.Sprintf("%s.%s = %v", configType.Name, fieldType.Name, field.Interface())) + }, + }) + } + if len(group.Sub) > 0 { + set.Sub = append(set.Sub, group) } - } else if strings.HasPrefix(c.Name, currentCommandText) { - // Suggest ourselves if we start with the current letter - entries = append(entries, c.Name) } - return + return set } -func (c Command) acceptsCurrentText(currentCommandText string) bool { - if c.Name == rootCommandIndicator { - return true +// elemOf dereferences a pointer value, leaving anything else alone. +func elemOf(value reflect.Value) reflect.Value { + if value.Kind() == reflect.Pointer { + return value.Elem() } - hasThePrefix := strings.HasPrefix(currentCommandText, c.Name) - hasNoMatchingAlternative := !strings.HasPrefix(currentCommandText, c.Name+"-") - accepts := hasThePrefix && hasNoMatchingAlternative - plc4xpcapanalyzerLog.Debug(). - Stringer("c", c). - Bool("accepts", accepts). - Msg("c accepts accepts") - return accepts + return value } -func (c Command) doesCommandTextTargetSubCommand(currentCommandText string) bool { - if c.Name == rootCommandIndicator { - return true - } - if len(c.subCommands) == 0 { - return false +// assign writes a textual value into a config field, converting it to the field's type. +func assign(field reflect.Value, value string) error { + switch field.Kind() { + case reflect.String: + field.SetString(value) + return nil + case reflect.Bool: + parsed, err := strconv.ParseBool(value) + if err != nil { + return errors.Errorf("%q is not a boolean, use true or false", value) + } + field.SetBool(parsed) + return nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return errors.Errorf("%q is not a number", value) + } + field.SetInt(parsed) + return nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + parsed, err := strconv.ParseUint(value, 10, 64) + if err != nil { + return errors.Errorf("%q is not a positive number", value) + } + field.SetUint(parsed) + return nil + default: + return errors.Errorf("fields of type %s cannot be set from the prompt", field.Type()) } - return strings.HasPrefix(currentCommandText, c.currentCommandPrefix()) } -func (c Command) prepareForParameters(currentCommandText string) string { - if currentCommandText == c.Name { - return "" +// describeConfigs renders every configuration value, for conf list. +func describeConfigs(configs AllCliConfigs) []string { + value := reflect.ValueOf(configs) + var lines []string + for i := range value.NumField() { + lines = append(lines, value.Type().Field(i).Name+":") + fields := elemOf(reflect.ValueOf(value.Field(i).Interface())) + if !fields.IsValid() || fields.Kind() != reflect.Struct { + continue + } + for j := range fields.NumField() { + fieldType := fields.Type().Field(j) + if fieldType.Tag.Get("json") == "-" { + continue + } + lines = append(lines, fmt.Sprintf(" %s: %s = %v", fieldType.Name, fieldType.Type, fields.Field(j).Interface())) + } } - return strings.TrimPrefix(currentCommandText, c.currentCommandPrefix()) -} -func (c Command) prepareForSubCommand(currentCommandText string) string { - return strings.TrimPrefix(currentCommandText, c.currentCommandPrefix()) + return lines } -func (c Command) currentCommandPrefix() string { - if c.Name == rootCommandIndicator { - return "" +// suggestDrivers completes a driver name. +func suggestDrivers(_ *State, partial string) []string { + var out []string + for _, driver := range DriverNames { + if strings.HasPrefix(driver, partial) { + out = append(out, driver) + } } - return c.Name + " " + return out } -func (c Command) hasDirectExecution() bool { - return c.action != nil -} - -func Execute(ctx context.Context, commandText string) error { - err := rootCommand.Execute(ctx, commandText) - if err == nil { - addCommandHistoryEntry(commandText) +// suggestDirectories completes a directory, relative to the session's current directory unless +// the partial is already absolute. +func suggestDirectories(state *State, partial string) []string { + base, prefix := completionBase(state, partial) + entries, err := os.ReadDir(base) + if err != nil { + return nil } - return err + var out []string + for _, entry := range entries { + if !entry.IsDir() || !strings.HasPrefix(entry.Name(), prefix) { + continue + } + out = append(out, completionJoin(partial, prefix, entry.Name())) + } + sort.Strings(out) + return out } -func (c Command) Execute(ctx context.Context, commandText string) (err error) { - defer func() { - if recoveredErr := recover(); recoveredErr != nil { - if log.Debug().Enabled() { - log.Error(). - Str("stack", string(debug.Stack())). - Interface("err", err). - Msg("panic-ed") +// captureExtensions are the file types open completes. +var captureExtensions = []string{".pcap", ".pcapng", ".cap"} + +// suggestCaptureFiles completes a capture file, offering the recently opened ones first +// because those are what a returning session usually wants. +func suggestCaptureFiles(state *State, partial string) []string { + var out []string + for _, recent := range state.Config.History.Last10Files { + if strings.HasPrefix(recent, partial) { + out = append(out, recent) + } + } + base, prefix := completionBase(state, partial) + entries, err := os.ReadDir(base) + if err != nil { + return out + } + for _, entry := range entries { + name := entry.Name() + if !strings.HasPrefix(name, prefix) { + continue + } + if entry.IsDir() { + out = append(out, completionJoin(partial, prefix, name)) + continue + } + for _, extension := range captureExtensions { + if strings.HasSuffix(strings.ToLower(name), extension) { + out = append(out, completionJoin(partial, prefix, name)) + break } - err = errors.Errorf("panic occurred: %v.", recoveredErr) } - }() - plc4xpcapanalyzerLog.Debug(). - Stringer("c", c).Str("commandText", commandText). - Msg("c executes commandText") - if !c.acceptsCurrentText(commandText) { - return errors.Errorf("%s doesn't understand %s", c.Name, commandText) - } - if c.doesCommandTextTargetSubCommand(commandText) { - prepareForSubCommandForSubCommand := c.prepareForSubCommand(commandText) - for _, command := range c.subCommands { - if command.acceptsCurrentText(prepareForSubCommandForSubCommand) { - plc4xpcapanalyzerLog.Debug(). - Stringer("c", c). - Str("commandText", commandText). - Msg("c delegates to sub command") - return command.Execute(ctx, prepareForSubCommandForSubCommand) + } + return out +} + +// suggestProtocolAndFile completes the " " argument analyze and extract +// take, offering the open captures once a protocol has been chosen. +func suggestProtocolAndFile(state *State, partial string) []string { + name, file, hasFile := strings.Cut(partial, " ") + if !hasFile { + var out []string + for _, proto := range protocol.All() { + if strings.HasPrefix(proto.Name, name) { + out = append(out, proto.Name) } } - return errors.Errorf("%s not accepted by any subcommands of %s", commandText, c.Name) - } else { - if c.action == nil { - return NotDirectlyExecutable + return out + } + proto, err := protocol.Resolve(name) + if err != nil { + return nil + } + var out []string + for _, capture := range state.Captures { + if strings.HasPrefix(capture.Path, file) || strings.HasPrefix(capture.Name, file) { + out = append(out, proto.Name+" "+capture.Path) } - plc4xpcapanalyzerLog.Debug(). - Stringer("c", c). - Str("commandText", commandText). - Msg("c executes commandText directly") - preparedForParameters := c.prepareForParameters(commandText) - return c.action(ctx, c, preparedForParameters) } + for _, candidate := range suggestCaptureFiles(state, file) { + out = append(out, proto.Name+" "+candidate) + } + return out } -func (c Command) visit(i int, f func(currentIndent int, command Command)) { - f(i, c) - for _, subCommand := range c.subCommands { - subCommand.visit(i+1, f) +// completionBase splits a partial path into the directory to read and the name prefix to +// match inside it. +func completionBase(state *State, partial string) (base, prefix string) { + if partial == "" { + return state.CurrentDir, "" + } + dir, name := filepath.Split(partial) + switch { + case dir == "": + return state.CurrentDir, name + case filepath.IsAbs(dir): + return dir, name + default: + return filepath.Join(state.CurrentDir, dir), name } } -func (c Command) String() string { - return c.Name +// completionJoin rebuilds a completion so that it replaces exactly what the user typed, +// keeping any directory part they had already entered. +func completionJoin(partial, prefix, name string) string { + return strings.TrimSuffix(partial, prefix) + name } diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/commands_test.go b/plc4go/tools/plc4xpcapanalyzer/ui/commands_test.go new file mode 100644 index 000000000..b47eafc16 --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/ui/commands_test.go @@ -0,0 +1,432 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package ui + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + cliConfig "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol" +) + +// run executes one command line against a session and returns what it produced. +func run(t *testing.T, state *State, line string) Outcome { + t.Helper() + return Root().Execute(t.Context(), state, line) +} + +// TestEveryCommandTheOldUiOfferedIsStillHere is the inventory check. +// +// The list is transcribed from the tview version's command tree. It is spelled out rather than +// derived so that removing a command from the tree fails here instead of quietly shipping. +func TestEveryCommandTheOldUiOfferedIsStillHere(t *testing.T) { + inventory := []string{ + "ls", "cd", "pwd", "open", "analyze", "extract", + "host set", "host get", + "register", "quit", + "log get", "log set", + "conf list", + "conf plc4xpcapanalyzer-debug on", "conf plc4xpcapanalyzer-debug off", + "conf auto-register list", "conf auto-register enable", "conf auto-register disable", + "plc4x-conf TraceTransactionManagerWorkers on", "plc4x-conf TraceTransactionManagerWorkers off", + "plc4x-conf TraceTransactionManagerTransactions on", "plc4x-conf TraceTransactionManagerTransactions off", + "plc4x-conf TraceDefaultMessageCodecWorker on", "plc4x-conf TraceDefaultMessageCodecWorker off", + "history", + "clear", "clear message", "clear console", "clear command", + "abort", "help", + } + root := Root() + for _, path := range inventory { + t.Run(path, func(t *testing.T) { + node, _, ok := root.resolve(path) + require.True(t, ok, "%q no longer resolves to a command", path) + last := path[strings.LastIndex(path, " ")+1:] + assert.Equal(t, last, node.Name, "%q resolved to %q instead", path, node.Name) + assert.NotNil(t, node.Run, "%q resolves but cannot be run", path) + }) + } +} + +func TestConfSetCoversEveryConfigurationStruct(t *testing.T) { + root := Root() + set, _, ok := root.resolve("conf set") + require.True(t, ok, "conf set is missing") + assert.NotEmpty(t, set.Sub, "conf set is a dispatcher; with no groups it can set nothing") + for _, group := range []string{"RootConfig", "AnalyzeConfig", "ExtractConfig", "BacnetConfig", "CBusConfig", "PcapConfig"} { + node, _, ok := root.resolve("conf set " + group) + require.True(t, ok, "conf set %s is missing", group) + assert.NotEmpty(t, node.Sub, "conf set %s offers no fields", group) + } +} + +func TestUnknownCommandNamesTheWayOut(t *testing.T) { + state := NewState(t.TempDir(), NewConfig()) + outcome := run(t, state, "frobnicate the widget") + require.Error(t, outcome.Err) + assert.Contains(t, outcome.Err.Error(), "frobnicate") + assert.Contains(t, outcome.Err.Error(), "help") +} + +func TestACommandThatNeedsASubcommandSaysWhichOnes(t *testing.T) { + state := NewState(t.TempDir(), NewConfig()) + outcome := run(t, state, "host") + require.Error(t, outcome.Err) + assert.Contains(t, outcome.Err.Error(), "set") + assert.Contains(t, outcome.Err.Error(), "get") +} + +func TestPwdAndCd(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, "captures"), 0o755)) + state := NewState(dir, NewConfig()) + + assert.Contains(t, run(t, state, "pwd").Lines[0], dir) + + outcome := run(t, state, "cd captures") + require.NoError(t, outcome.Err) + assert.Equal(t, filepath.Join(dir, "captures"), state.CurrentDir) + + failed := run(t, state, "cd nowhere") + assert.Error(t, failed.Err) +} + +func TestCdRefusesAFile(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.pcap"), []byte("x"), 0o600)) + state := NewState(dir, NewConfig()) + + outcome := run(t, state, "cd a.pcap") + require.Error(t, outcome.Err) + assert.Contains(t, outcome.Err.Error(), "not a directory") +} + +func TestLsListsTheDirectory(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.pcap"), []byte("x"), 0o600)) + require.NoError(t, os.Mkdir(filepath.Join(dir, "sub"), 0o755)) + state := NewState(dir, NewConfig()) + + outcome := run(t, state, "ls") + require.NoError(t, outcome.Err) + joined := strings.Join(outcome.Lines, "\n") + assert.Contains(t, joined, "a.pcap") + assert.Contains(t, joined, "sub"+string(os.PathSeparator)) +} + +func TestOpenSelectsTheCapture(t *testing.T) { + state, demo := demoState(t) + state.Captures, state.Current = nil, -1 + + outcome := run(t, state, "open "+DemoCaptureName) + require.NoError(t, outcome.Err) + capture, ok := state.CurrentCapture() + require.True(t, ok) + assert.Equal(t, demo.Path, capture.Path) + assert.Contains(t, state.Config.History.Last10Files, demo.Path, + "opening a capture must put it in the recent list, which is what open completes from") +} + +func TestOpeningTheSameCaptureTwiceSelectsItRatherThanFailing(t *testing.T) { + state, _ := demoState(t) + before := len(state.Captures) + + outcome := run(t, state, "open "+DemoCaptureName) + require.NoError(t, outcome.Err) + assert.Len(t, state.Captures, before, "an already-open capture must be selected, not duplicated") +} + +func TestOpenReportsAMissingFile(t *testing.T) { + state := NewState(t.TempDir(), NewConfig()) + outcome := run(t, state, "open nope.pcap") + require.Error(t, outcome.Err) + assert.Contains(t, outcome.Err.Error(), "nope.pcap") +} + +func TestAnalyzeReturnsARequestRatherThanDoingTheWork(t *testing.T) { + state, demo := demoState(t) + + outcome := run(t, state, "analyze c-bus "+demo.Path) + require.NoError(t, outcome.Err) + require.NotNil(t, outcome.Analysis, "the command layer must hand the model a request, not run it") + assert.Equal(t, protocol.CBus.Name, outcome.Analysis.Protocol.Name) + assert.Equal(t, demo.Path, outcome.Analysis.PcapFile) + assert.Equal(t, demo.Client, outcome.Analysis.Client) +} + +// TestAnalyzeAcceptsTheBacnetAlias is the regression guard for the defect the protocol registry +// was introduced to fix: the CLI accepted "bacnet" and the analyzer only ever matched +// "bacnetip". +func TestAnalyzeAcceptsTheBacnetAlias(t *testing.T) { + state, demo := demoState(t) + + outcome := run(t, state, "analyze bacnet "+demo.Path) + require.NoError(t, outcome.Err) + require.NotNil(t, outcome.Analysis) + assert.Equal(t, protocol.BacnetIP.Name, outcome.Analysis.Protocol.Name) +} + +func TestAnalyzeWithNoArgumentsUsesTheSelectedCapture(t *testing.T) { + state, demo := demoState(t) + + outcome := run(t, state, "analyze") + require.NoError(t, outcome.Err) + require.NotNil(t, outcome.Analysis) + assert.Equal(t, demo.Path, outcome.Analysis.PcapFile) +} + +func TestAnalyzeWithNothingOpenSaysWhatToDo(t *testing.T) { + pinGlobals(t) + state := NewState(t.TempDir(), NewConfig()) + + outcome := run(t, state, "analyze") + require.Error(t, outcome.Err) + assert.Contains(t, outcome.Err.Error(), "open ") +} + +func TestAnalyzeRejectsAnUnknownProtocol(t *testing.T) { + state, demo := demoState(t) + outcome := run(t, state, "analyze modbus "+demo.Path) + require.Error(t, outcome.Err) + assert.Contains(t, outcome.Err.Error(), "modbus") +} + +func TestExtractReturnsAnExtraction(t *testing.T) { + state, demo := demoState(t) + outcome := run(t, state, "extract c-bus "+demo.Path) + require.NoError(t, outcome.Err) + require.NotNil(t, outcome.Extraction) + assert.Nil(t, outcome.Analysis) + assert.Equal(t, demo.Path, outcome.Extraction.PcapFile) +} + +func TestHostSetAndGet(t *testing.T) { + state := NewState(t.TempDir(), NewConfig()) + + assert.Contains(t, run(t, state, "host get").Lines[0], "host set ") + + require.NoError(t, run(t, state, "host set 10.0.0.1").Err) + assert.Equal(t, "10.0.0.1", state.HostIP) + assert.Equal(t, "10.0.0.1", state.Config.HostIp, "the address has to survive the session") + assert.Contains(t, run(t, state, "host get").Lines[0], "10.0.0.1") +} + +func TestRegisterAndUnregisterADriver(t *testing.T) { + state := NewState(t.TempDir(), NewConfig()) + + require.NoError(t, run(t, state, "register c-bus").Err) + assert.True(t, state.IsRegistered("c-bus")) + assert.NotNil(t, state.DriverManager) + + assert.Error(t, run(t, state, "register c-bus").Err, "registering twice must say so rather than silently succeed") + assert.Error(t, run(t, state, "register nonsense").Err) + + require.NoError(t, run(t, state, "unregister c-bus").Err) + assert.False(t, state.IsRegistered("c-bus")) + assert.Error(t, run(t, state, "unregister c-bus").Err) +} + +func TestLogSetChangesTheLevel(t *testing.T) { + state := NewState(t.TempDir(), NewConfig()) + + require.NoError(t, run(t, state, "log set debug").Err) + assert.Equal(t, zerolog.DebugLevel, state.LogLevel) + assert.Equal(t, "debug", state.Config.LogLevel) + assert.Contains(t, run(t, state, "log get").Lines[0], "debug") + + assert.Error(t, run(t, state, "log set shouting").Err) +} + +// TestConfSetConvertsToTheFieldsType is the fix for a real panic: the old implementation called +// reflect.Value.SetString on every field, so setting any bool or uint blew up and the +// dispatcher's recover reported it as "panic occurred". +func TestConfSetConvertsToTheFieldsType(t *testing.T) { + pinGlobals(t) + state := NewState(t.TempDir(), NewConfig()) + + // Filter lives on PcapConfig, which AnalyzeConfig embeds. The reflection walks declared + // fields only, so the embedded struct is addressed by its own name — the same shape the + // previous version had. + require.NoError(t, run(t, state, "conf set PcapConfig Filter tcp port 10001").Err) + assert.Equal(t, "tcp port 10001", cliConfig.AnalyzeConfigInstance.Filter) + + require.NoError(t, run(t, state, "conf set AnalyzeConfig OnlyParse true").Err) + assert.True(t, cliConfig.AnalyzeConfigInstance.OnlyParse) + + require.NoError(t, run(t, state, "conf set PcapConfig PackageNumberLimit 42").Err) + assert.Equal(t, uint(42), cliConfig.PcapConfigInstance.PackageNumberLimit) + + require.NoError(t, run(t, state, "conf set RootConfig Verbosity 3").Err) + assert.Equal(t, 3, cliConfig.RootConfigInstance.Verbosity) +} + +func TestConfSetRejectsAValueOfTheWrongType(t *testing.T) { + pinGlobals(t) + state := NewState(t.TempDir(), NewConfig()) + + outcome := run(t, state, "conf set AnalyzeConfig OnlyParse maybe") + require.Error(t, outcome.Err) + assert.Contains(t, outcome.Err.Error(), "true or false") + assert.NotContains(t, outcome.Err.Error(), "panic") +} + +func TestConfListShowsTheCurrentValues(t *testing.T) { + pinGlobals(t) + cliConfig.AnalyzeConfigInstance.Filter = "udp port 47808" + state := NewState(t.TempDir(), NewConfig()) + + joined := strings.Join(run(t, state, "conf list").Lines, "\n") + assert.Contains(t, joined, "AnalyzeConfig") + assert.Contains(t, joined, "udp port 47808") +} + +func TestAutoRegister(t *testing.T) { + state := NewState(t.TempDir(), NewConfig()) + + assert.Contains(t, run(t, state, "conf auto-register list").Lines[0], "conf auto-register enable") + + require.NoError(t, run(t, state, "conf auto-register enable c-bus").Err) + assert.Equal(t, []string{"c-bus"}, state.Config.AutoRegisterDrivers) + assert.Error(t, run(t, state, "conf auto-register enable c-bus").Err) + assert.Error(t, run(t, state, "conf auto-register enable nonsense").Err) + + require.NoError(t, run(t, state, "conf auto-register disable c-bus").Err) + assert.Empty(t, state.Config.AutoRegisterDrivers) + assert.Error(t, run(t, state, "conf auto-register disable c-bus").Err) +} + +func TestDebugToggle(t *testing.T) { + state := NewState(t.TempDir(), NewConfig()) + require.NoError(t, run(t, state, "conf plc4xpcapanalyzer-debug on").Err) + assert.True(t, state.Debug) + require.NoError(t, run(t, state, "conf plc4xpcapanalyzer-debug off").Err) + assert.False(t, state.Debug) +} + +func TestHistoryListsTheRememberedCommands(t *testing.T) { + state := NewState(t.TempDir(), NewConfig()) + assert.Contains(t, run(t, state, "history").Lines[0], "no commands") + + state.Config.RememberCommand("open a.pcap") + state.Config.RememberCommand("analyze c-bus a.pcap") + joined := strings.Join(run(t, state, "history").Lines, "\n") + assert.Contains(t, joined, "open a.pcap") + assert.Contains(t, joined, "analyze c-bus a.pcap") +} + +func TestClearTargetsTheRightPane(t *testing.T) { + state := NewState(t.TempDir(), NewConfig()) + + all := run(t, state, "clear") + assert.True(t, all.ClearPackets && all.ClearLog && all.ClearTranscript) + + assert.True(t, run(t, state, "clear message").ClearPackets) + assert.False(t, run(t, state, "clear message").ClearLog) + assert.True(t, run(t, state, "clear console").ClearLog) + assert.True(t, run(t, state, "clear command").ClearTranscript) +} + +func TestQuitAndAbortAreEffectsRatherThanActions(t *testing.T) { + state := NewState(t.TempDir(), NewConfig()) + assert.True(t, run(t, state, "quit").Quit) + assert.True(t, run(t, state, "abort").Abort) +} + +func TestHelpListsEveryCommand(t *testing.T) { + state := NewState(t.TempDir(), NewConfig()) + joined := strings.Join(run(t, state, "help").Lines, "\n") + for _, name := range []string{"analyze", "extract", "conf", "auto-register", "plc4x-conf", "abort"} { + assert.Contains(t, joined, name) + } +} + +func TestCompletionsAreWholeLines(t *testing.T) { + state, demo := demoState(t) + + for _, testCase := range []struct { + line string + want string + }{ + {"an", "analyze"}, + {"conf ", "conf list"}, + {"conf a", "conf auto-register"}, + {"log s", "log set"}, + {"log set de", "log set debug"}, + {"register c", "register c-bus"}, + {"analyze c", "analyze c-bus"}, + {"analyze c-bus ", "analyze c-bus " + demo.Path}, + } { + t.Run(testCase.line, func(t *testing.T) { + candidates := Root().Completions(state, testCase.line) + assert.Contains(t, candidates, testCase.want) + for _, candidate := range candidates { + assert.True(t, strings.HasPrefix(strings.ToLower(candidate), strings.ToLower(strings.TrimRight(testCase.line, " "))), + "%q is not a replacement for %q; textinput matches a candidate against the whole line", candidate, testCase.line) + } + }) + } +} + +// TestCompletionsAreSilentOnAnEmptyLine keeps a menu of every command from appearing under the +// prompt the moment it takes focus. +func TestCompletionsAreSilentOnAnEmptyLine(t *testing.T) { + state := NewState(t.TempDir(), NewConfig()) + assert.Empty(t, Root().Completions(state, "")) + assert.Empty(t, Root().Completions(state, " ")) +} + +func TestOpenCompletesCaptureFilesAndDirectories(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "trace.pcap"), []byte("x"), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("x"), 0o600)) + require.NoError(t, os.Mkdir(filepath.Join(dir, "more"), 0o755)) + state := NewState(dir, NewConfig()) + + candidates := Root().Completions(state, "open ") + assert.Contains(t, candidates, "open trace.pcap") + assert.Contains(t, candidates, "open more") + assert.NotContains(t, candidates, "open notes.txt", "a text file is not a capture") +} + +func TestCdCompletesOnlyDirectories(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "trace.pcap"), []byte("x"), 0o600)) + require.NoError(t, os.Mkdir(filepath.Join(dir, "more"), 0o755)) + state := NewState(dir, NewConfig()) + + candidates := Root().Completions(state, "cd ") + assert.Equal(t, []string{"cd more"}, candidates) +} + +func TestValidateDriverNamesTheKnownOnes(t *testing.T) { + err := ValidateDriver("modbus") + require.Error(t, err) + for _, driver := range DriverNames { + assert.Contains(t, err.Error(), driver) + } + assert.True(t, slices.Contains(DriverNames, "c-bus")) +} diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/common.go b/plc4go/tools/plc4xpcapanalyzer/ui/common.go deleted file mode 100644 index 58e170b2a..000000000 --- a/plc4go/tools/plc4xpcapanalyzer/ui/common.go +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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. - */ - -package ui - -import ( - "context" - "io" - "os" - "strings" - "sync" - "time" - - plc4go "github.com/apache/plc4x/plc4go/pkg/api" - apiModel "github.com/apache/plc4x/plc4go/pkg/api/model" - "github.com/apache/plc4x/plc4go/spi" - - "github.com/rs/zerolog" -) - -const protocols = "ads,bacnetip,c-bus,s7" - -var protocolList = strings.Split(protocols, ",") - -var plc4xpcapanalyzerLog = zerolog.Nop() - -var driverManager plc4go.PlcDriverManager -var driverAdded func(string) - -type loadedPcapFile struct { - name string - path string -} - -var loadedPcapFiles []loadedPcapFile -var loadedPcapFilesChanged func() - -var messageReceived func(messageNumber int, receiveTime time.Time, message apiModel.PlcMessage) -var numberOfMessagesReceived int -var spiMessageReceived func(messageNumber int, receiveTime time.Time, message spi.Message) -var spiNumberOfMessagesReceived int -var messageOutput io.Writer -var messageOutputClear func() - -var consoleOutput io.Writer -var consoleOutputClear func() - -var commandsExecuted int -var commandOutput io.Writer -var commandOutputClear func() - -var currentDirChanged func() -var currentDir = func() string { - dir, _ := os.Getwd() - return dir -}() - -var rootContext = context.Background() -var cancelFunctions = make(map[uint32]context.CancelFunc) - -var shutdownMutex sync.Mutex -var hasShutdown bool - -func Shutdown() { - shutdownMutex.Lock() - defer shutdownMutex.Unlock() - if hasShutdown { - return - } - hasShutdown = true - saveConfig() -} diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/config.go b/plc4go/tools/plc4xpcapanalyzer/ui/config.go index 2c21c4b15..a6eeebc93 100644 --- a/plc4go/tools/plc4xpcapanalyzer/ui/config.go +++ b/plc4go/tools/plc4xpcapanalyzer/ui/config.go @@ -21,40 +21,50 @@ package ui import ( "os" - "path" + "path/filepath" + "reflect" + "slices" "time" - cliConfig "github.com/apache/plc4x/plc4go-extras/tools/plc4xpcapanalyzer/config" - - "github.com/pkg/errors" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" + "github.com/apache/plc4x/plc4go/spi/errors" "gopkg.in/yaml.v3" + + cliConfig "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config" ) -var plc4xpcapanalyzerConfigDir string -var configFile string -var config = Config{ - MaxConsoleLines: 500, - MaxOutputLines: 500, - CliConfigs: allCliConfigsInstances, -} +// The session configuration this tool remembers between runs. +// +// It is loaded and saved explicitly rather than in an init function. The version this replaces +// created the configuration directory from a package-level init and panicked when it could +// not, so merely importing the package for a test wrote to the user's home directory and, on a +// machine where os.UserConfigDir fails, took the whole process down before main ran. +// ConfigFileName is the file the session configuration is kept in, inside the tool's directory +// under the user's configuration directory. +const ConfigFileName = "config.yml" + +// HistoryLimit is how many recent files and recent commands are remembered. The field names +// below still say "last 10" because that is what the on-disk format calls them. +const HistoryLimit = 10 + +// Config is what survives between sessions. type Config struct { HostIp string `yaml:"host_ip"` History struct { Last10Files []string `yaml:"last_hosts"` Last10Commands []string `yaml:"last_commands"` - } + } `yaml:"history"` AutoRegisterDrivers []string `yaml:"auto_register_driver"` LastUpdated time.Time `yaml:"last_updated"` LogLevel string `yaml:"log_level"` MaxConsoleLines int `yaml:"max_console_lines"` MaxOutputLines int `yaml:"max_output_lines"` - CliConfigs allCliConfigs `yaml:"cli_configs"` + CliConfigs AllCliConfigs `yaml:"cli_configs"` } -type allCliConfigs struct { +// AllCliConfigs is the set of CLI configuration singletons the conf command reflects over. +// The pointers are shared with the cobra layer, so a value set here is the value a run uses. +type AllCliConfigs struct { RootConfig *cliConfig.RootConfig AnalyzeConfig *cliConfig.AnalyzeConfig ExtractConfig *cliConfig.ExtractConfig @@ -63,143 +73,227 @@ type allCliConfigs struct { PcapConfig *cliConfig.PcapConfig } -var allCliConfigsInstances = allCliConfigs{ - &cliConfig.RootConfigInstance, - &cliConfig.AnalyzeConfigInstance, - &cliConfig.ExtractConfigInstance, - &cliConfig.BacnetConfigInstance, - &cliConfig.CBusConfigInstance, - &cliConfig.PcapConfigInstance, +// CliConfigInstances points at the live singletons. +func CliConfigInstances() AllCliConfigs { + return AllCliConfigs{ + RootConfig: &cliConfig.RootConfigInstance, + AnalyzeConfig: &cliConfig.AnalyzeConfigInstance, + ExtractConfig: &cliConfig.ExtractConfigInstance, + BacnetConfig: &cliConfig.BacnetConfigInstance, + CBusConfig: &cliConfig.CBusConfigInstance, + PcapConfig: &cliConfig.PcapConfigInstance, + } +} + +// NewConfig returns the defaults a fresh installation starts from. +func NewConfig() Config { + config := Config{ + MaxConsoleLines: 500, + MaxOutputLines: 500, + CliConfigs: CliConfigInstances(), + } + return config } -func init() { +// ConfigDir is the directory the session configuration lives in. It returns an error rather +// than panicking, because a tool that cannot find a home directory should still start. +func ConfigDir() (string, error) { userConfigDir, err := os.UserConfigDir() if err != nil { - panic(err) - } - plc4xpcapanalyzerConfigDir = path.Join(userConfigDir, "plc4xpcapanalyzer") - if _, err := os.Stat(plc4xpcapanalyzerConfigDir); os.IsNotExist(err) { - err := os.Mkdir(plc4xpcapanalyzerConfigDir, os.ModeDir|os.ModePerm) - if err != nil { - panic(err) - } + return "", errors.Wrap(err, "error resolving the user configuration directory") } - configFile = path.Join(plc4xpcapanalyzerConfigDir, "config.yml") + return filepath.Join(userConfigDir, "plc4xpcapanalyzer"), nil } -func LoadConfig() { - f, err := os.Open(configFile) +// ConfigPath is the full path of the session configuration file. +func ConfigPath() (string, error) { + dir, err := ConfigDir() if err != nil { - log.Info().Err(err).Msg("No config file found") - return + return "", err } - defer func(f *os.File) { - err := f.Close() - if err != nil { - log.Error().Err(err).Msg("Error closing config file") + return filepath.Join(dir, ConfigFileName), nil +} + +// LoadConfigFrom reads the configuration at path. A missing file is not an error: it is what a +// first run looks like, and the defaults are correct for it. +func LoadConfigFrom(path string) (Config, error) { + config := NewConfig() + file, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return config, nil } - }(f) + return config, errors.Wrapf(err, "error opening %s", path) + } + defer func() { _ = file.Close() }() - decoder := yaml.NewDecoder(f) - if err = decoder.Decode(&config); err != nil { - log.Warn().Err(err).Msg("Can't decode config file") - return + // Decode the CLI settings into structs of their own rather than over the live singletons. + // They used to be decoded straight through the shared pointers, so the file overwrote + // whatever the command line had just put there: a value persisted months ago beat the flag + // typed a second ago, silently and with no way to override it. + config.CliConfigs = detachedCliConfigs() + if err := yaml.NewDecoder(file).Decode(&config); err != nil { + // A corrupt file must not stop the tool starting, so the defaults are returned + // alongside the error and the caller decides how loudly to complain. + return NewConfig(), errors.Wrapf(err, "error decoding %s", path) } + persisted := config.CliConfigs + // Point the set back at the live singletons so conf set reaches the real config, then let + // the persisted values fill in only what the command line did not ask for. A file written + // by an older version may carry no CLI settings at all, which is simply nothing to apply. + config.CliConfigs = CliConfigInstances() + applyPersistedCliConfigs(persisted) + return config, nil } -func saveConfig() { - config.LastUpdated = time.Now() - f, err := os.OpenFile(configFile, os.O_RDWR|os.O_CREATE, 0755) - if err != nil { - log.Warn().Err(err).Msg("Can't save config file") - return +// detachedCliConfigs is the same shape as CliConfigInstances, pointing at fresh structs, so +// that decoding a file into it cannot reach the live singletons. +func detachedCliConfigs() AllCliConfigs { + return AllCliConfigs{ + RootConfig: &cliConfig.RootConfig{}, + AnalyzeConfig: &cliConfig.AnalyzeConfig{}, + ExtractConfig: &cliConfig.ExtractConfig{}, + BacnetConfig: &cliConfig.BacnetConfig{}, + CBusConfig: &cliConfig.CBusConfig{}, + PcapConfig: &cliConfig.PcapConfig{}, } - encoder := yaml.NewEncoder(f) - defer func(encoder *yaml.Encoder) { - err := encoder.Close() - if err != nil { - log.Error().Err(err).Msg("Error closing config file") +} + +// defaultCliConfigs is the same shape again, pointing at the snapshot of the singletons taken +// before any command line was parsed. The second result says whether there is a snapshot. +func defaultCliConfigs() (AllCliConfigs, bool) { + snapshot, taken := cliConfig.DefaultsSnapshot() + return AllCliConfigs{ + RootConfig: &snapshot.Root, + AnalyzeConfig: &snapshot.Analyze, + ExtractConfig: &snapshot.Extract, + BacnetConfig: &snapshot.Bacnet, + CBusConfig: &snapshot.CBus, + PcapConfig: &snapshot.Pcap, + }, taken +} + +// applyPersistedCliConfigs copies persisted CLI settings onto the live singletons, field by +// field, leaving alone anything the command line has already asked for. +// +// The order is the conventional one: a built-in default is overridden by the session +// configuration, and the session configuration is overridden by a flag. A flag is identified +// by its field no longer holding the default recorded before parsing. That test has one known +// limit, and it is the benign direction: a flag passed with exactly its default value looks +// unset, so the persisted value wins for that field. +// +// With no snapshot to compare against -- nothing called config.SnapshotDefaults, which in +// practice means a test rather than the tool -- every non-zero value is treated as deliberate +// and the persisted settings only fill in fields that are still zero. +func applyPersistedCliConfigs(persisted AllCliConfigs) { + live := CliConfigInstances() + defaults, _ := defaultCliConfigs() + + liveValue := reflect.ValueOf(live) + persistedValue := reflect.ValueOf(persisted) + defaultValue := reflect.ValueOf(defaults) + + for i := range liveValue.NumField() { + liveFields := elemOf(liveValue.Field(i)) + persistedFields := elemOf(persistedValue.Field(i)) + defaultFields := elemOf(defaultValue.Field(i)) + if !liveFields.IsValid() || !persistedFields.IsValid() || !defaultFields.IsValid() { + continue + } + if liveFields.Kind() != reflect.Struct || persistedFields.Kind() != reflect.Struct { + continue + } + + for j := range liveFields.NumField() { + fieldType := liveFields.Type().Field(j) + // The same fields conf set exposes, and for the same reason: the embedded configs + // are pointers shared between these structs, and each is applied in its own right. + if fieldType.Tag.Get("json") == "-" || fieldType.Anonymous { + continue + } + liveField := liveFields.Field(j) + if !liveField.CanSet() || !liveField.Comparable() { + continue + } + if liveField.Interface() != defaultFields.Field(j).Interface() { + // The command line asked for this one. + continue + } + liveField.Set(persistedFields.Field(j)) } - }(encoder) - if err := encoder.Encode(config); err != nil { - log.Warn().Err(err).Msg("Can't encode config file") - panic(err) } } -func addRecentFilesEntry(pcapFile string) { - existingIndex := -1 - for i, lastPcapFile := range config.History.Last10Files { - if lastPcapFile == pcapFile { - existingIndex = i - break - } +// SaveConfigTo writes the configuration to path, creating the directory if it is missing. +func SaveConfigTo(path string, config Config, now time.Time) error { + config.LastUpdated = now + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return errors.Wrapf(err, "error creating %s", filepath.Dir(path)) } - if existingIndex >= 0 { - config.History.Last10Files = append(config.History.Last10Files[:existingIndex], config.History.Last10Files[existingIndex+1:]...) + file, err := os.Create(path) + if err != nil { + return errors.Wrapf(err, "error creating %s", path) } - if len(config.History.Last10Files) >= 10 { - config.History.Last10Files = config.History.Last10Files[1:] + defer func() { _ = file.Close() }() + + encoder := yaml.NewEncoder(file) + if err := encoder.Encode(config); err != nil { + _ = encoder.Close() + return errors.Wrapf(err, "error encoding %s", path) } - config.History.Last10Files = append(config.History.Last10Files, pcapFile) + return errors.Wrapf(encoder.Close(), "error closing %s", path) +} + +// RememberFile records a capture in the recent-files list, most recent last, without +// duplicates. +func (c *Config) RememberFile(pcapFile string) { + c.History.Last10Files = remember(c.History.Last10Files, pcapFile) } -func addCommandHistoryEntry(command string) { +// RememberCommand records a command in the recent-commands list. +// +// clear and history are skipped, as they were before: they are the two commands whose whole +// purpose is to inspect or reset the session, and remembering them crowds out the commands a +// user actually wants to recall. +func (c *Config) RememberCommand(command string) { switch command { - case "clear": - return - case "history": + case "clear", "history": return } - existingIndex := -1 - for i, lastCommand := range config.History.Last10Commands { - if lastCommand == command { - existingIndex = i - break - } + c.History.Last10Commands = remember(c.History.Last10Commands, command) +} + +// remember appends entry, moving an existing occurrence to the end and dropping the oldest +// once the limit is reached. +func remember(entries []string, entry string) []string { + if entry == "" { + return entries } - if existingIndex >= 0 { - config.History.Last10Commands = append(config.History.Last10Commands[:existingIndex], config.History.Last10Commands[existingIndex+1:]...) + if index := slices.Index(entries, entry); index >= 0 { + entries = slices.Delete(entries, index, index+1) } - if len(config.History.Last10Commands) >= 10 { - config.History.Last10Commands = config.History.Last10Commands[1:] + entries = append(entries, entry) + if len(entries) > HistoryLimit { + entries = slices.Delete(entries, 0, len(entries)-HistoryLimit) } - config.History.Last10Commands = append(config.History.Last10Commands, command) -} - -func setLevel(level zerolog.Level) { - config.LogLevel = level.String() + return entries } -func enableAutoRegister(driver string) error { - if err := validateDriverParam(driver); err != nil { - return err +// EnableAutoRegister adds a driver to the set registered at startup. +func (c *Config) EnableAutoRegister(driver string) error { + if slices.Contains(c.AutoRegisterDrivers, driver) { + return errors.Errorf("%s already registered for auto register", driver) } - for _, autoRegisterDriver := range config.AutoRegisterDrivers { - if autoRegisterDriver == driver { - return errors.Errorf("%s already registered for auto register", driver) - } - } - config.AutoRegisterDrivers = append(config.AutoRegisterDrivers, driver) - log.Info().Str("driver", driver).Msg("Auto register enabled") + c.AutoRegisterDrivers = append(c.AutoRegisterDrivers, driver) return nil } -func disableAutoRegister(driver string) error { - if err := validateDriverParam(driver); err != nil { - return err - } - index := -1 - for i, autoRegisterDriver := range config.AutoRegisterDrivers { - if autoRegisterDriver == driver { - index = i - break - } - } +// DisableAutoRegister removes a driver from the set registered at startup. +func (c *Config) DisableAutoRegister(driver string) error { + index := slices.Index(c.AutoRegisterDrivers, driver) if index < 0 { return errors.Errorf("%s not registered for auto register", driver) } - config.AutoRegisterDrivers = append(config.AutoRegisterDrivers[:index], config.AutoRegisterDrivers[index+1:]...) - log.Info().Str("driver", driver).Msg("Auto register disabled") + c.AutoRegisterDrivers = slices.Delete(c.AutoRegisterDrivers, index, index+1) return nil } diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/demo.go b/plc4go/tools/plc4xpcapanalyzer/ui/demo.go new file mode 100644 index 000000000..1f64a78fc --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/ui/demo.go @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package ui + +import ( + "os" + "path/filepath" + + "github.com/apache/plc4x/plc4go/spi/errors" + + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/pcapfixture" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol" +) + +// Demo mode. +// +// The analyzers parse raw captured bytes: there is no connection, no handshake and no device +// anywhere in the path. So demo mode does not need a fake driver, and it does not need a +// capture committed to the repository either — it writes one, from the same fixture builder +// the tests use, containing genuine C-Bus traffic taken from plc4x's own protocol tests. +// +// The session it generates deliberately includes payloads the codec CANNOT parse. A demo where +// everything succeeds shows none of what this tool is for; this one produces findings, so the +// Findings tab, the failure counters and the detail pane all have something real in them. + +// DemoCaptureName is the file the generated capture is written as. It is the name the sidebar +// shows, so it says what it is. +const DemoCaptureName = "cbus-demo.pcap" + +// Demo is a generated capture, and everything needed to analyse it. +type Demo struct { + // Dir is the temporary directory holding the capture. Cleanup removes it. + Dir string + // Path is the capture. + Path string + // Protocol is what the capture contains. + Protocol protocol.Protocol + // Client is the address the capture's requests come from, which C-Bus needs in order to + // tell a request from a response. + Client string +} + +// NewDemo writes a demo capture into a fresh temporary directory. +func NewDemo() (Demo, error) { + dir, err := os.MkdirTemp("", "plc4xpcapanalyzer-demo-") + if err != nil { + return Demo{}, errors.Wrap(err, "error creating the demo directory") + } + demo, err := NewDemoIn(dir) + if err != nil { + _ = os.RemoveAll(dir) + return Demo{}, err + } + return demo, nil +} + +// NewDemoIn writes a demo capture into an existing directory. Tests use it with t.TempDir so +// that nothing has to be cleaned up by hand. +func NewDemoIn(dir string) (Demo, error) { + path := filepath.Join(dir, DemoCaptureName) + if err := pcapfixture.WriteCBus(path, pcapfixture.CBusSessionWithFailures()); err != nil { + return Demo{}, errors.Wrap(err, "error writing the demo capture") + } + return Demo{ + Dir: dir, + Path: path, + Protocol: protocol.CBus, + Client: pcapfixture.ClientIP, + }, nil +} + +// Cleanup removes the generated capture. It is safe to call on a zero Demo, and safe to call +// twice, so a deferred call needs no guard. +func (d Demo) Cleanup() error { + if d.Dir == "" { + return nil + } + return errors.Wrapf(os.RemoveAll(d.Dir), "error removing the demo directory %s", d.Dir) +} diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/help_test.go b/plc4go/tools/plc4xpcapanalyzer/ui/help_test.go new file mode 100644 index 000000000..e022dc782 --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/ui/help_test.go @@ -0,0 +1,171 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package ui + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/tui" + "github.com/apache/plc4x-extras/plc4go/tools/internal/tuitest" +) + +// The expanded help is meant to be a complete and honest statement of what a focus can do. +// These tests hold it to both halves of that: nothing advertised may be inert, and nothing that +// works may go unadvertised. The browser has had the first half since the port; the analyzer +// had neither, which is how its eight most tool-specific keys came to work while appearing in +// neither the footer nor the expanded help. + +// idempotentByDesign are the advertised bindings a "something changed" assertion cannot express. +// Each is listed with the reason, and each is covered by a test of its own elsewhere. +var idempotentByDesign = map[string]string{ + // esc is a dismissal chain: with nothing to dismiss it is correctly a no-op. + "esc": "dismisses one thing at a time, and does nothing when there is nothing to dismiss", +} + +// TestEveryAdvertisedKeyDoesSomething walks the bindings the expanded help actually shows and +// presses each one, so a binding cannot be advertised without being handled. +func TestEveryAdvertisedKeyDoesSomething(t *testing.T) { + state, _ := demoState(t) + reference := newTestModel(t, state, wide) + reference = send(reference, modKey(tea.KeyTab, tea.ModShift)) + require.Equal(t, tui.FocusPane, reference.focus) + + groups := append(reference.keys.FullHelp(), reference.tools.fullHelp()...) + seen := 0 + for _, group := range groups { + for _, binding := range group { + if len(binding.Keys()) == 0 { + // Out of scope at this focus, so the help does not show it either. + continue + } + pressed := binding.Keys()[0] + if reason, skip := idempotentByDesign[pressed]; skip { + t.Logf("skipping %q: %s", pressed, reason) + continue + } + seen++ + + t.Run(pressed, func(t *testing.T) { + model := analysed(t, okRecord(1), brokenRecord(2), okRecord(3)) + model = send(model, modKey(tea.KeyTab, tea.ModShift)) + // Park the cursor mid-list so that both ends of the list have somewhere to go. + model = send(model, charKey('G')) + model = send(model, charKey('k')) + // The three detail views are a radio group, so pressing the one already + // showing is correctly a no-op. The test switches to a different one first + // rather than excluding them: diff is the default, and it is the view the + // whole tool exists for, so leaving it untested would be the wrong trade. + switch pressed { + case "b", "t": + model = send(model, charKey('d')) + case "d": + model = send(model, charKey('b')) + } + + before := model.Render() + next, cmd := sendWithCmd(model, keyPressFor(pressed)) + // A key may act on the screen or produce a command -- yank does the latter. + assert.True(t, next.Render() != before || cmd != nil, + "%q (%s) changed nothing; the help advertises it, so it must do something", + pressed, binding.Help().Desc) + }) + } + } + assert.Positive(t, seen, "the help has to advertise something") +} + +// TestEveryToolBindingIsAdvertised is the other direction, and the one the analyzer failed: +// its own bindings never reached the help, so reading the documentation was the only way to +// discover them. +func TestEveryToolBindingIsAdvertised(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, tui.Size{Width: 130, Height: 44}) + model = send(model, modKey(tea.KeyTab, tea.ModShift)) + model = send(model, namedKey(tea.KeyF1)) + require.True(t, model.footer.ShowAll(), "F1 should open the expanded help") + + screen := tuitest.Strip(model.Render()) + for _, group := range model.tools.fullHelp() { + for _, binding := range group { + require.NotEmpty(t, binding.Keys(), "a tool binding must be bound at pane focus") + help := binding.Help() + assert.Contains(t, screen, help.Key+" "+help.Desc, + "the expanded help has to advertise %q", help.Key) + } + } +} + +// TestToolBindingsAreSilentAtThePrompt is why they are pane-scoped: they are bare letters, and +// typing "analyze" must not fire the a, n, l, y and z bindings. +func TestToolBindingsAreSilentAtThePrompt(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, wide) + require.Equal(t, tui.FocusPrompt, model.focus) + + for _, group := range toolKeysFor(tui.FocusPrompt).fullHelp() { + for _, binding := range group { + assert.Empty(t, binding.Keys(), + "%s must not be bound at the prompt", binding.Help().Desc) + } + } + + // And the letters reach the field as text. + for _, r := range "analyze" { + model = send(model, charKey(r)) + } + assert.Equal(t, "analyze", model.prompt.Value()) + assert.False(t, model.run.active, "typing a command must not have started one") +} + +// keyPressFor builds the key press a terminal sends for a binding's key name. +func keyPressFor(name string) tea.KeyPressMsg { + switch name { + case "enter": + return namedKey(tea.KeyEnter) + case "esc": + return namedKey(tea.KeyEscape) + case "tab": + return namedKey(tea.KeyTab) + case "shift+tab": + return modKey(tea.KeyTab, tea.ModShift) + case "up": + return namedKey(tea.KeyUp) + case "down": + return namedKey(tea.KeyDown) + case "pgup": + return namedKey(tea.KeyPgUp) + case "pgdown": + return namedKey(tea.KeyPgDown) + case "f1": + return namedKey(tea.KeyF1) + } + if strings.HasPrefix(name, "alt+") && len(name) == 5 { + return modKey(rune(name[4]), tea.ModAlt) + } + if strings.HasPrefix(name, "ctrl+") && len(name) == 6 { + return modKey(rune(name[5]), tea.ModCtrl) + } + return charKey(rune(name[0])) +} diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/helpers_test.go b/plc4go/tools/plc4xpcapanalyzer/ui/helpers_test.go new file mode 100644 index 000000000..8af87080a --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/ui/helpers_test.go @@ -0,0 +1,145 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package ui + +import ( + "os" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/rs/zerolog" + "github.com/stretchr/testify/require" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/tui" + cliConfig "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config" +) + +// Test helpers. +// +// Two pieces of process-global state have to be pinned in every test that touches an analysis: +// the CLI configuration singletons, which are where the analysis options live, and the global +// zerolog logger, which the C-Bus analyzer writes several lines per packet to. + +// TestMain silences the global logger for the whole package. +// +// The plc4x driver registration and the C-Bus analyzer both log several lines per call through +// it, and a test run that prints a thousand JSON records buries its own failures. +func TestMain(m *testing.M) { + zerolog.SetGlobalLevel(zerolog.Disabled) + os.Exit(m.Run()) +} + +// testTheme is the theme every view test renders with: no colour and the ASCII glyph set, so +// that an assertion on the content is an assertion on the content and a width is a count of +// characters. +func testTheme() tui.Options { + return tui.Options{Dark: true, NoColor: true, ASCII: true} +} + +// fixedClock is a clock that does not move, so an elapsed time is whatever the test says it is. +func fixedClock() func() time.Time { + base := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + return func() time.Time { return base } +} + +// pinGlobals saves and restores the configuration singletons and the logger. +func pinGlobals(t *testing.T) { + t.Helper() + savedRoot := cliConfig.RootConfigInstance + savedPcap := cliConfig.PcapConfigInstance + savedAnalyze := cliConfig.AnalyzeConfigInstance + savedExtract := cliConfig.ExtractConfigInstance + savedBacnet := cliConfig.BacnetConfigInstance + savedCBus := cliConfig.CBusConfigInstance + savedLevel := zerolog.GlobalLevel() + t.Cleanup(func() { + cliConfig.RootConfigInstance = savedRoot + cliConfig.PcapConfigInstance = savedPcap + cliConfig.AnalyzeConfigInstance = savedAnalyze + cliConfig.ExtractConfigInstance = savedExtract + cliConfig.BacnetConfigInstance = savedBacnet + cliConfig.CBusConfigInstance = savedCBus + zerolog.SetGlobalLevel(savedLevel) + }) + // The C-Bus analyzer logs at debug for every packet, which would bury a test's own output. + zerolog.SetGlobalLevel(zerolog.Disabled) + cliConfig.RootConfigInstance.HideProgressBar = true +} + +// demoState builds a session with a generated demo capture open, in a directory that the test +// framework removes. +func demoState(t *testing.T) (*State, Demo) { + t.Helper() + pinGlobals(t) + demo, err := NewDemoIn(t.TempDir()) + require.NoError(t, err) + + state := NewState(demo.Dir, NewConfig()) + state.Demo = true + state.Protocol = demo.Protocol + state.HostIP = demo.Client + _, err = state.Open(demo.Path) + require.NoError(t, err) + return state, demo +} + +// newTestModel builds a model at a size, with a fixed clock and a stable theme. +func newTestModel(t *testing.T, state *State, size tui.Size) Model { + t.Helper() + return newTestModelWithTheme(t, state, size, testTheme()) +} + +// newTestModelWithTheme is newTestModel with the theme chosen by the caller, so that a test can +// exercise the coloured themes rather than only the plain one the assertions read. +func newTestModelWithTheme(t *testing.T, state *State, size tui.Size, options tui.Options) Model { + t.Helper() + model := NewModel(Options{Theme: options, State: state, Clock: fixedClock()}) + return send(model, tea.WindowSizeMsg{Width: size.Width, Height: size.Height}) +} + +// send delivers one message and returns the model that came back. +func send(model Model, msg tea.Msg) Model { + next, _ := model.Update(msg) + return next.(Model) +} + +// sendWithCmd delivers one message and returns the model and the command it produced. +func sendWithCmd(model Model, msg tea.Msg) (Model, tea.Cmd) { + next, cmd := model.Update(msg) + return next.(Model), cmd +} + +// charKey builds the key press a terminal sends for a printable character. Text has to be set +// as well as Code, because the key name is taken from Text and that is what key.Matches +// compares against. +func charKey(r rune) tea.KeyPressMsg { + return tea.KeyPressMsg{Code: r, Text: string(r)} +} + +// namedKey builds the key press for a named key, which carries no text. +func namedKey(code rune) tea.KeyPressMsg { + return tea.KeyPressMsg{Code: code} +} + +// modKey builds a modified key press, such as alt+3 or shift+tab. +func modKey(code rune, mod tea.KeyMod) tea.KeyPressMsg { + return tea.KeyPressMsg{Code: code, Mod: mod} +} diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/keys.go b/plc4go/tools/plc4xpcapanalyzer/ui/keys.go new file mode 100644 index 000000000..a1c65e860 --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/ui/keys.go @@ -0,0 +1,611 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package ui + +import ( + "context" + "strconv" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/tui" +) + +// Key handling. +// +// The order below is the whole of the rule the design review insisted on: a bare character is +// a pane action only while a pane has the keyboard, and text everywhere else. Nothing here +// compares a Focus to decide that — every binding was resolved for the current focus by +// tui.KeyMap.ForFocus and tui.Scoped, so an out-of-scope binding simply has no keys and +// key.Matches refuses it. + +// handleKey routes one key press. +func (m Model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + // The quit question is modal and answered before anything else, including the filter line + // and the prompt: a key that reached the text field while it was up would leave the + // question on screen with no way to tell what answered it. + if m.confirmQuit { + return m.answerQuit(msg) + } + // Quit comes first, at every focus and in every mode. Whatever else the user has got + // themselves into, the way out has to work -- but ctrl+c stops what is happening rather + // than ending the session. A user watching an analysis grind through a large capture + // reaches for it to stop the run, and throwing the results away instead is not a + // reasonable answer to that keystroke. With nothing running it asks, because q and ctrl+c + // are both easy to hit by accident. + if key.Matches(msg, m.keys.Quit) { + if m.run.active && !m.run.aborting { + return m.abortRun(), nil + } + // Once the abort is under way, or with nothing running, the keystroke means the + // session. A run that is slow to stop must not swallow every press. + m.confirmQuit = true + return m, nil + } + // ctrl+d offers to end the session too, as the tview interface did, but only where there + // is nothing to delete: bubbles/textinput binds it to delete-forward, so quitting + // unconditionally would take a line-editing key away. This is the rule every shell uses. + if key.Matches(msg, m.keys.EOF) && !m.filtering && (m.focus != tui.FocusPrompt || m.prompt.Value() == "") { + m.confirmQuit = true + return m, nil + } + // The filter line is text entry, so it claims every other key before anything else looks + // at it. + if m.filtering { + return m.handleFilterKey(msg) + } + + switch { + case key.Matches(msg, m.keys.Help): + m.footer.SetShowAll(!m.footer.ShowAll()) + return m, nil + + case key.Matches(msg, m.keys.Cancel): + return m.handleEscape() + + case key.Matches(msg, m.keys.PaneJump), m.focus == tui.FocusPrompt && m.prompt.Value() == "" && isPaneDigit(msg): + // A bare digit as well as alt+digit: GNOME Terminal, Konsole and Windows Terminal all + // bind alt+digit to switching terminal tabs and never deliver it to the application, so + // alt alone leaves the hotkeys unreachable. A digit cannot be text at an empty prompt, + // and no command begins with one, so nothing is shadowed. + return m.jumpToPane(msg), nil + + case key.Matches(msg, m.keys.PageUp): + return m.scroll(-m.pageSize()), nil + + case key.Matches(msg, m.keys.PageDown): + return m.scroll(m.pageSize()), nil + } + + if m.focus == tui.FocusPrompt { + if key.Matches(msg, m.keys.FocusPanes) { + cmd := m.setFocus(tui.FocusPane) + return m, cmd + } + var cmd tea.Cmd + m.prompt, cmd = m.prompt.Update(msg) + return m, cmd + } + return m.handlePaneKey(msg) +} + +// quit ends the program, cancelling anything still in flight on the way out. +// +// The cancellation is not cosmetic: an analysis publishes each record with a blocking send +// guarded only by its own context, so quitting without cancelling left that goroutine parked +// on a send that nothing would ever drain, still holding the capture open. +func (m Model) quit() (tea.Model, tea.Cmd) { + if m.cancelRun != nil { + m.cancelRun() + m.cancelRun = nil + } + m.quitting = true + return m, tea.Quit +} + +// answerQuit resolves the quit question. +// +// Only an explicit yes ends the session. Anything else takes the question down and is swallowed +// rather than passed on, so a stray keystroke cannot both dismiss the question and do something +// else the user did not see coming. +func (m Model) answerQuit(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, m.keys.Quit), key.Matches(msg, m.keys.Submit): + // ctrl+c again confirms, which is the pattern every shell has taught. + return m.quit() + case msg.String() == "y", msg.String() == "Y": + return m.quit() + } + m.confirmQuit = false + return m, nil +} + +// handleEscape walks the dismissal chain, most local first. Each link is only taken when the +// one before it had nothing to dismiss, so one press never does two things. +func (m Model) handleEscape() (tea.Model, tea.Cmd) { + switch { + case m.prompt.ConsumesEscape(): + var cmd tea.Cmd + m.prompt, cmd = m.prompt.Update(tea.KeyPressMsg{Code: tea.KeyEscape}) + return m, cmd + case m.footer.ShowAll(): + m.footer.SetShowAll(false) + return m, nil + case m.overlay != paneCount: + m.overlay = paneCount + return m, nil + case m.toast != "": + m.setToast("", false) + return m, nil + case m.run.active: + return m.abortRun(), nil + case m.focus != tui.FocusPrompt: + return m, m.setFocus(tui.FocusPrompt) + } + return m, nil +} + +// isPaneDigit reports whether a key press is a bare digit naming a pane. +func isPaneDigit(msg tea.KeyPressMsg) bool { + name := msg.String() + if len(name) != 1 { + return false + } + digit := lastDigit(name) + return digit >= 1 && digit <= int(paneCount) +} + +// jumpToPane handles alt+1 to alt+4, and the bare digits while a pane has the keyboard. +// +// Selecting a pane that the current layout has no column for opens it as an overlay instead of +// doing nothing, so the binding is never dead. +func (m Model) jumpToPane(msg tea.KeyPressMsg) Model { + digit := lastDigit(msg.String()) + if digit < 1 || digit > int(paneCount) { + return m + } + target := pane(digit - 1) + if m.paneVisible(target) { + m.overlay = paneCount + m.pane = target + } else { + m.overlay = target + } + if m.focus != tui.FocusPane { + _ = m.setFocus(tui.FocusPane) + } + return m +} + +// lastDigit pulls the digit out of a key name such as "alt+3" or "3". +func lastDigit(name string) int { + if name == "" { + return -1 + } + digit, err := strconv.Atoi(name[len(name)-1:]) + if err != nil { + return -1 + } + return digit +} + +// handlePaneKey handles the bindings that are live only while a pane has the keyboard. +func (m Model) handlePaneKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, m.keys.FocusPrompt): + return m, m.setFocus(tui.FocusPrompt) + + case key.Matches(msg, m.keys.NextPane): + m.pane = m.nextPane(1) + return m, nil + + case key.Matches(msg, m.keys.PrevPane): + m.pane = m.nextPane(-1) + return m, nil + + case key.Matches(msg, m.keys.Up): + return m.scroll(-1), nil + + case key.Matches(msg, m.keys.Down): + return m.scroll(1), nil + + case key.Matches(msg, m.keys.Top): + return m.scrollTo(0), nil + + case key.Matches(msg, m.keys.Bottom): + return m.scrollTo(1 << 30), nil + + case key.Matches(msg, m.keys.Select): + return m.activate() + + case key.Matches(msg, m.keys.Filter): + if m.activePane() == paneMain && m.tab != tabLog { + m.filtering = true + return m, m.setFocus(tui.FocusOverlay) + } + m.setToast("filter applies to the packets and findings tabs", false) + return m, nil + + case key.Matches(msg, m.keys.Wrap): + m.detailWrap = !m.detailWrap + m.setToast("detail wrap "+onOff(m.detailWrap), false) + return m, nil + + case key.Matches(msg, m.keys.Yank): + return m.yank(), nil + + case key.Matches(msg, m.keys.Follow): + m.follow = !m.follow + m.setToast("follow "+onOff(m.follow), false) + return m, nil + + case key.Matches(msg, m.keys.Expand): + return m.expand(), nil + + case key.Matches(msg, m.keys.LogLevel): + return m.cycleLogLevel(), nil + + case key.Matches(msg, m.tools.TabNext): + m.tab = tab((int(m.tab) + 1) % int(tabCount)) + m.refilter() + return m, nil + + case key.Matches(msg, m.tools.TabPrev): + m.tab = tab((int(m.tab) - 1 + int(tabCount)) % int(tabCount)) + m.refilter() + return m, nil + + case key.Matches(msg, m.tools.ViewBytes): + m.detail = detailBytes + return m, nil + + case key.Matches(msg, m.tools.ViewTree): + m.detail = detailTree + return m, nil + + case key.Matches(msg, m.tools.ViewDiff): + m.detail = detailDiff + return m, nil + + case key.Matches(msg, m.tools.Open): + return m.prefill("open ") + + case key.Matches(msg, m.tools.Analyze): + return m.prefill(m.prefilledRun("analyze")) + + case key.Matches(msg, m.tools.Extract): + return m.prefill(m.prefilledRun("extract")) + } + return m, nil +} + +// handleFilterKey drives the inline filter entry in the main pane's border. +func (m Model) handleFilterKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, m.keys.Cancel): + m.filtering = false + m.filter = "" + m.refilter() + return m, m.setFocus(tui.FocusPane) + case key.Matches(msg, m.keys.Submit): + m.filtering = false + m.refilter() + return m, m.setFocus(tui.FocusPane) + } + switch msg.Code { + case tea.KeyBackspace: + if runes := []rune(m.filter); len(runes) > 0 { + m.filter = string(runes[:len(runes)-1]) + m.refilter() + } + return m, nil + } + if text := msg.Text; text != "" { + m.filter += text + m.refilter() + } + return m, nil +} + +// prefill puts a command in the prompt and hands the keyboard back to it, so that the bare +// shortcut is a head start on typing rather than a hidden action. +func (m Model) prefill(line string) (tea.Model, tea.Cmd) { + cmd := m.setFocus(tui.FocusPrompt) + m.prompt.SetValue(line) + return m, cmd +} + +// prefilledRun builds "analyze " from what the session already has open. +func (m Model) prefilledRun(verb string) string { + line := verb + " " + m.state.Protocol.Name + " " + if capture, ok := m.state.CurrentCapture(); ok { + line += capture.Path + } + return line +} + +// activate does whatever selecting the highlighted row means in the focused pane. +// +// Every row that can be highlighted does something. The version this replaces attached +// selection handlers whose entire body was a TODO comment. +func (m Model) activate() (tea.Model, tea.Cmd) { + switch m.activePane() { + case paneCaptures: + return m.activateSidebarRow() + case paneMain: + if m.tab == tabLog { + return m, nil + } + // Selecting a packet moves the detail pane onto it, and in compact mode brings that + // pane into view, since there is no column for it there. + if _, ok := m.selectedRecord(); !ok { + m.setToast("no packet selected", false) + return m, nil + } + // The detail pane already follows the cursor, so selecting a packet means "take me to + // it": as a column where there is one, as an overlay where there is not. + if m.paneVisible(paneDetail) { + m.pane = paneDetail + } else { + m.overlay = paneDetail + } + return m, nil + case paneRun: + if m.run.active { + return m.abortRun(), nil + } + if m.run.label == "" { + m.setToast("nothing has been run yet", false) + return m, nil + } + return m.prefill(m.prefilledRun("analyze")) + } + return m, nil +} + +// yank copies the detail pane's current text into the log, where the terminal's own selection +// can reach it. There is no clipboard dependency here on purpose: these tools run over ssh as +// often as not, and a clipboard write that silently does nothing is worse than a visible copy. +func (m Model) yank() Model { + record, ok := m.selectedRecord() + if !ok { + m.setToast("no packet selected", false) + return m + } + m.appendLog("packet " + itoa(record.Number) + " " + record.Verdict.String()) + m.appendLog(m.detailLines(record, 200, 78)...) + m.setToast("packet "+itoa(record.Number)+" written to the log", false) + m.tab = tabLog + return m +} + +// expand writes the selected record's full reason into the log, for the errors that are too +// long for the table's verdict column. +func (m Model) expand() Model { + record, ok := m.selectedRecord() + if !ok { + m.setToast("no packet selected", false) + return m + } + if record.Reason == "" { + m.setToast("packet "+itoa(record.Number)+" has nothing to expand", false) + return m + } + m.appendLog("packet " + itoa(record.Number) + ": " + record.Reason) + m.tab = tabLog + m.setToast("", false) + return m +} + +// cycleLogLevel steps the level the log pane is filtered at. +func (m Model) cycleLogLevel() Model { + levels := logLevels + current := m.state.LogLevel.String() + next := levels[0] + for i, level := range levels { + if level == current { + next = levels[(i+1)%len(levels)] + break + } + } + outcome := m.root.Execute(context.Background(), m.state, "log set "+next) + m.appendLog(outcome.Lines...) + m.setToast("log level "+next, false) + return m +} + +// scroll moves the focused pane's cursor by delta rows. +func (m Model) scroll(delta int) Model { + switch m.activePane() { + case paneCaptures: + m.sidebarCursor = clampIndex(m.sidebarCursor+delta, len(m.sidebarSelectable())) + case paneMain: + if m.tab == tabLog { + m.logTop = clampIndex(m.logTop+delta, len(m.logLines)) + return m + } + m.cursor = clampIndex(m.cursor+delta, len(m.visible)) + m = m.revealCursor() + // Moving the cursor by hand means the user has taken over from the run, so following + // stops. Leaving it on would yank the selection away on the next batch of packets. + if delta != 0 { + m.follow = false + } + case paneDetail: + m.detailTop = clampIndex(m.detailTop+delta, m.detailExtent()) + } + return m +} + +// revealCursor scrolls the packet window the minimum distance needed to show the cursor. +// +// The window only moves when the cursor would leave it, which is what keeps the selection +// moving inside a stationary list instead of dragging the whole list with it. +func (m Model) revealCursor() Model { + rows := max(m.geometry().PaneHeight-3, 1) + switch { + case m.cursor < m.packetTop: + m.packetTop = m.cursor + case m.cursor >= m.packetTop+rows: + m.packetTop = m.cursor - rows + 1 + } + m.packetTop = min(max(m.packetTop, 0), max(len(m.visible)-1, 0)) + return m +} + +// scrollTo jumps the focused pane's cursor to an absolute row. +func (m Model) scrollTo(row int) Model { + switch m.activePane() { + case paneCaptures: + m.sidebarCursor = clampIndex(row, len(m.sidebarSelectable())) + case paneMain: + if m.tab == tabLog { + m.logTop = clampIndex(row, len(m.logLines)) + return m + } + m.cursor = clampIndex(row, len(m.visible)) + m = m.revealCursor() + m.follow = false + case paneDetail: + m.detailTop = clampIndex(row, m.detailExtent()) + } + return m +} + +// detailExtent is how many lines the detail pane's current view holds for the selected packet, +// and so how far it can be scrolled. +// +// The offset used to be clamped against an arbitrary ceiling instead. Forty presses of down in +// a two-row hex dump therefore cost forty presses of up to undo, and in the diff view — which +// draws a fixed block and does not scroll at all — they cost nothing visible and then landed +// the bytes view at the end of its dump for no reason the user could see. +func (m Model) detailExtent() int { + record, ok := m.selectedRecord() + if !ok { + return 0 + } + switch m.detail { + case detailBytes: + return len(HexDumpLines(record.Original, hexBytesPerRow, 1<<16)) + case detailTree: + lines := splitLines(record.Tree) + if m.detailWrap { + lines = wrapLines(lines, m.detailInnerWidth()) + } + return len(lines) + default: + return 0 + } +} + +// pageSize is how far a page key moves, which is the height of the focused pane's content. +// +// It comes from geometry rather than from tui.Layout.BodyHeight: Compute reserves rows there +// for a log drawer, and this tool draws the log as a tab of the main pane instead, so the +// layout's figure is three rows short of what is actually on screen. +func (m Model) pageSize() int { + return max(m.geometry().PaneHeight-2, 1) +} + +// activePane is the pane the keyboard is acting on: the overlay when one is open, otherwise +// the focused column. +func (m Model) activePane() pane { + if m.overlay != paneCount { + return m.overlay + } + return m.pane +} + +// paneVisible reports whether a pane has a place in the current layout. +func (m Model) paneVisible(p pane) bool { + switch p { + case paneCaptures: + return m.geometry().SidebarWidth > 0 + case paneMain: + return true + case paneDetail: + // The same test the renderer applies. A pane that tab can reach but the renderer does + // not draw is a keyboard black hole. + return m.geometry().DetailHeight > 0 + case paneRun: + return m.run.label != "" + default: + return false + } +} + +// visiblePanes are the panes tab cycles through, in order. +func (m Model) visiblePanes() []pane { + var panes []pane + for p := range paneCount { + if m.paneVisible(p) { + panes = append(panes, p) + } + } + return panes +} + +// nextPane steps the focus by delta places through the visible panes, wrapping. +func (m Model) nextPane(delta int) pane { + panes := m.visiblePanes() + if len(panes) == 0 { + return paneMain + } + index := 0 + for i, p := range panes { + if p == m.pane { + index = i + break + } + } + index = (index + delta + len(panes)) % len(panes) + return panes[index] +} + +// clampIndex constrains an index to [0, length), returning zero for an empty collection. +func clampIndex(index, length int) int { + if length <= 0 { + return 0 + } + return min(max(index, 0), length-1) +} + +// onOff renders a toggle for a toast. +func onOff(on bool) string { + if on { + return "on" + } + return "off" +} + +// itoa is strconv.Itoa under a shorter name, because the view builds a great many small +// strings and the longer name buries them. +func itoa(n int) string { return strconv.Itoa(n) } + +// splitLines splits text into lines, dropping a trailing empty one. +func splitLines(text string) []string { + lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + return lines +} diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/logsink.go b/plc4go/tools/plc4xpcapanalyzer/ui/logsink.go new file mode 100644 index 000000000..480aee230 --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/ui/logsink.go @@ -0,0 +1,106 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package ui + +import ( + "bytes" + "strings" + "sync" +) + +// Getting text out of a writer and into the log pane. +// +// Everything that writes text here — zerolog, the extractor's output — writes it from a +// goroutine the model does not own. So neither of these types touches the model: they split +// what they are given into lines and hand each one to a callback, and the model's callback +// does nothing but put it on a channel that a re-arming command drains. + +// lineWriter turns an io.Writer into a stream of lines. +// +// It buffers a partial line rather than emitting it, because zerolog's console writer emits a +// record in several Write calls and a pane full of fragments is worse than one that is a beat +// behind. Flush releases whatever is left. +type lineWriter struct { + emit lineWriterFunc + + mu sync.Mutex + partial bytes.Buffer +} + +// newLineWriter builds a lineWriter that hands each complete line to emit. +func newLineWriter(emit lineWriterFunc) *lineWriter { + if emit == nil { + emit = func(string) {} + } + return &lineWriter{emit: emit} +} + +// Write implements io.Writer. It is safe to call from any goroutine, because the writers it is +// handed to make no such promise about which one they use. +func (w *lineWriter) Write(p []byte) (int, error) { + w.mu.Lock() + lines := w.consume(p) + w.mu.Unlock() + for _, line := range lines { + w.emit(line) + } + return len(p), nil +} + +// consume appends p to the buffer and returns the complete lines it now holds. The caller must +// hold w.mu; the emitting is done outside the lock so a slow consumer cannot deadlock a +// concurrent writer. +func (w *lineWriter) consume(p []byte) []string { + w.partial.Write(p) + buffered := w.partial.String() + index := strings.LastIndexByte(buffered, '\n') + if index < 0 { + return nil + } + complete := buffered[:index] + w.partial.Reset() + w.partial.WriteString(buffered[index+1:]) + return strings.Split(strings.TrimSuffix(complete, "\r"), "\n") +} + +// Flush emits any partial line still buffered. +func (w *lineWriter) Flush() { + w.mu.Lock() + rest := strings.TrimSuffix(w.partial.String(), "\r") + w.partial.Reset() + w.mu.Unlock() + if rest != "" { + w.emit(rest) + } +} + +// channelSink is the model's line callback: it puts a line on a channel and nothing else. +// +// The send is non-blocking. A log line that cannot be delivered is dropped rather than allowed +// to throttle whatever produced it — an analysis must not run at the speed of a display, and +// the pane is bounded anyway. +func channelSink(ch chan string) lineWriterFunc { + return func(line string) { + select { + case ch <- line: + default: + } + } +} diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/model.go b/plc4go/tools/plc4xpcapanalyzer/ui/model.go new file mode 100644 index 000000000..546c974b6 --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/ui/model.go @@ -0,0 +1,902 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +// Package ui is the terminal interface of plc4xpcapanalyzer. +// +// It is a Bubble Tea v2 program built on the presentation vocabulary in tools/internal/tui. +// The shape it replaces was a tview grid whose widgets were mutated directly from analysis +// goroutines, whose command area was added with a zero row and column span below a hundred +// columns — so the prompt was simply not drawn and the user typed blind — and whose panes gave +// no indication of which of them had the keyboard. None of those are possible here: state is +// only ever changed on the event loop, the prompt is a reserved row in every layout, and the +// focused pane is drawn differently from the rest. +package ui + +import ( + "context" + "strings" + "time" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/progress" + "github.com/apache/plc4x-extras/plc4go/tools/internal/tui" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/extractor" +) + +// AppName and AppVersion title the status bar. +const ( + AppName = "PLC4X Pcap Analyzer" + AppVersion = "1.0.0-SNAPSHOT" +) + +// Channel capacities. Each one is drained by a command that reads it and re-arms itself, so +// the buffer only has to absorb what arrives between two turns of the event loop. +const ( + progressBuffer = 64 + logBuffer = 512 + recordBuffer = 512 + // recordBatch caps how many records one drain returns. Handing them over one at a time + // would put a whole turn of the event loop between consecutive packets of a six-thousand + // packet capture; handing over everything at once would let a single message stall a + // resize. + recordBatch = 256 +) + +// pane identifies a region of the layout. The order is the order tab cycles in, and the order +// alt+1 to alt+4 select. +type pane int + +const ( + paneCaptures pane = iota + paneMain + paneDetail + paneRun + paneCount +) + +// String names a pane, for the status bar and for test failures. +func (p pane) String() string { + switch p { + case paneCaptures: + return "captures" + case paneMain: + return "packets" + case paneDetail: + return "detail" + case paneRun: + return "run" + default: + return "unknown" + } +} + +// tab identifies one view of the main pane. +type tab int + +const ( + tabPackets tab = iota + tabLog + tabFindings + tabCount +) + +// String names a tab, which is what the tab strip in the main pane's border shows. +func (t tab) String() string { + switch t { + case tabPackets: + return "Packets" + case tabLog: + return "Log" + case tabFindings: + return "Findings" + default: + return "?" + } +} + +// detailView identifies what the detail pane is showing. +type detailView int + +const ( + // detailDiff is the default because the byte comparison is what this tool exists to do. + detailDiff detailView = iota + detailBytes + detailTree +) + +// Messages. Everything that happens off the event loop reaches the model as one of these. +type ( + // progressMsg is one frame from a running analysis or extraction. + progressMsg struct{ Update progress.Update } + // logMsg is one line written to the log pane, whether by zerolog, by a command or by an + // extraction's output. + logMsg struct{ Lines []string } + // recordsMsg is a batch of packets a running analysis has finished with. + recordsMsg struct{ Records []Record } + // analysisDoneMsg carries the authoritative outcome of a run. + analysisDoneMsg struct{ Result Result } + // extractionDoneMsg says an extraction finished. + extractionDoneMsg struct { + Request Request + Err error + } +) + +// runKind says which long-running job is in flight. +type runKind int + +const ( + runKindNone runKind = iota + runKindAnalysis + runKindExtraction +) + +// runState is what the run panel draws. +type runState struct { + kind runKind + label string + phase string + current int + total int + // counters accumulate from the streamed records while the run is in flight and are + // replaced by the authoritative totals when it finishes. + counters Counters + active bool + aborted bool + // aborting records that a cancellation has been asked for but the run has not finished + // reacting to it yet. Without it a second ctrl+c would abort the same run again, so a run + // that was slow to stop left the user with no way to reach the quit question at all. + aborting bool + err error + started time.Time + elapsed time.Duration +} + +// Model is the root Bubble Tea model. +type Model struct { + // Presentation. + themeOptions tui.Options + theme tui.Theme + keys tui.KeyMap + tools toolKeys + footer tui.HelpFooter + prompt tui.Prompt + + size tui.Size + layout tui.Layout + + // Session. + state *State + root Command + + // Focus. + focus tui.Focus + pane pane + // overlay is the pane shown full-width in compact mode, or paneCount when none is. + overlay pane + + // Main pane. + tab tab + packets []Record + visible []int + cursor int + // packetTop is the first visible row of the packet table. + // + // It is kept as state rather than derived from the cursor because deriving it pinned the + // cursor to the bottom visible row: every press of up or down then scrolled the whole list + // instead of moving the selection inside a stationary window, so the highlighted row was + // never anywhere but the last drawn line. The window now moves only when the cursor + // reaches an edge. + packetTop int + filter string + filtering bool + follow bool + + // Log pane. + logLines []string + logTop int + + // Detail pane. + detail detailView + detailWrap bool + detailTop int + + // Sidebar. + sidebarCursor int + + // Run. + run runState + spinnerFrame int + cancelRun context.CancelFunc + + // Toast. + toast string + toastErr bool + + // Wiring. + progressCh chan progress.Update + logCh chan string + recordCh chan Record + now func() time.Time + // analyze runs one analysis. It is a field so a test can substitute a run that finishes + // instantly instead of reading a capture. + analyze func(ctx context.Context, request Request, sink Sink) Result + // extract runs one extraction, for the same reason. + extract func(ctx context.Context, request Request, reporter progress.Reporter, out lineWriterFunc) error + // autoRun is the analysis to start as soon as the program does, which is how demo mode + // arrives at something to look at. + autoRun *Request + + quitting bool + + // confirmQuit holds the "really quit?" question. ctrl+c used to end the session outright, + // which is the wrong default while an analysis is running: the same keystroke is how a + // user stops a run that is taking too long. + confirmQuit bool +} + +// Options configure a Model. +type Options struct { + // Theme selects the colour and glyph variant. Zero value means resolve from the + // environment on the first background-colour message. + Theme tui.Options + // State is the session state. Required. + State *State + // Clock is the time source, injected so that elapsed times are assertable. + Clock func() time.Time + // AutoRun is an analysis to start immediately, used by demo mode. + AutoRun *Request +} + +// NewModel builds the root model. +func NewModel(options Options) Model { + state := options.State + if state == nil { + state = NewState(".", NewConfig()) + } + clock := options.Clock + if clock == nil { + clock = time.Now + } + + model := Model{ + themeOptions: options.Theme, + state: state, + root: Root(), + focus: tui.FocusPrompt, + pane: paneMain, + overlay: paneCount, + tab: tabPackets, + detail: detailDiff, + follow: true, + progressCh: make(chan progress.Update, progressBuffer), + logCh: make(chan string, logBuffer), + recordCh: make(chan Record, recordBuffer), + now: clock, + analyze: Analyze, + extract: runExtraction, + autoRun: options.AutoRun, + run: runState{phase: PhaseIndex}, + } + model.applyTheme(tui.NewTheme(options.Theme)) + model.prompt.SetClock(clock) + // The prompt takes the keyboard here rather than in Init. Init has a value receiver, so + // anything it changes about the model is changed on a copy the program throws away; only + // the command it returns survives. Focusing there left the prompt blurred and the first + // thing the user typed went nowhere. + _ = model.prompt.Focus() + return model +} + +// applyTheme rebuilds everything whose appearance depends on the theme. +// +// The prompt is only rebuilt while its history is empty, which in practice means at startup, +// when the terminal answers the background-colour request. Rebuilding it later would discard +// the command history, and a terminal whose background changes mid-session is a far rarer +// event than one that answers the initial query. +func (m *Model) applyTheme(theme tui.Theme) { + m.theme = theme + m.footer = tui.NewHelpFooter(theme) + m.footer.SetWidth(m.size.Width) + if len(m.prompt.History()) == 0 { + value := m.prompt.Value() + busy := m.prompt.Busy() + focused := m.prompt.Focused() + m.prompt = tui.NewPrompt(theme, m.suggest) + m.prompt.SetClock(m.now) + m.prompt.SetValue(value) + m.prompt.SetWidth(m.size.Width) + if busy { + m.prompt.Start() + } + // The rebuilt prompt starts blurred, so whoever had the keyboard has to be given it + // back; otherwise answering the background-colour query silently swallows typing. + if focused { + _ = m.prompt.Focus() + } + } + m.keys = tui.NewKeyMap().ForFocus(m.focus) + m.tools = toolKeysFor(m.focus) +} + +// suggest is the prompt's completion source. +func (m *Model) suggest(currentText string) []string { + return m.root.Completions(m.state, currentText) +} + +// Init starts the program. +func (m Model) Init() tea.Cmd { + cmds := []tea.Cmd{ + // The terminal answers with a BackgroundColorMsg, which is what decides the light or + // dark palette. It also arrives again whenever the background changes. + tea.RequestBackgroundColor, + m.prompt.Focus(), + waitForProgress(m.progressCh), + waitForLog(m.logCh), + waitForRecords(m.recordCh), + } + if m.autoRun != nil { + cmds = append(cmds, func() tea.Msg { return startAnalysisMsg{Request: *m.autoRun} }) + } + return tea.Batch(cmds...) +} + +// startAnalysisMsg asks the model to start an analysis. It exists so that demo mode can queue +// a run from Init without duplicating the bookkeeping Update already does. +type startAnalysisMsg struct{ Request Request } + +// Update handles one message. +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + return m.resize(tui.Size{Width: msg.Width, Height: msg.Height}), nil + + case tea.BackgroundColorMsg: + options := m.themeOptions + options.Dark = msg.IsDark() + m.themeOptions = options + m.applyTheme(tui.NewTheme(options)) + return m, nil + + case tea.KeyPressMsg: + return m.handleKey(msg) + + case tea.MouseMsg: + return m.handleMouse(msg) + + case tui.PromptSubmitMsg: + return m.runLine(msg.Line) + + case tui.PromptTickMsg: + if frames := len(m.theme.Glyphs.Spinner); frames > 0 { + m.spinnerFrame = (m.spinnerFrame + 1) % frames + } + var cmd tea.Cmd + m.prompt, cmd = m.prompt.Update(msg) + return m, cmd + + case startAnalysisMsg: + return m.startAnalysis(msg.Request) + + case progressMsg: + if m.run.active { + m.run.current = msg.Update.Current + m.run.total = msg.Update.Total + if msg.Update.Description != "" { + m.run.phase = msg.Update.Description + } + m.run.elapsed = m.now().Sub(m.run.started) + } + return m, waitForProgress(m.progressCh) + + case logMsg: + m.appendLog(msg.Lines...) + return m, waitForLog(m.logCh) + + case recordsMsg: + if m.run.active && m.run.kind == runKindAnalysis { + for _, record := range msg.Records { + m.packets = append(m.packets, record) + m.run.counters = fold(m.run.counters, record) + } + m.refilter() + if m.follow { + m.cursor = max(len(m.visible)-1, 0) + m = m.revealCursor() + } + } + return m, waitForRecords(m.recordCh) + + case analysisDoneMsg: + return m.finishAnalysis(msg.Result) + + case extractionDoneMsg: + m.run.active = false + m.run.err = msg.Err + m.run.elapsed = m.now().Sub(m.run.started) + m.prompt.Finish() + m.cancelRun = nil + if msg.Err != nil { + m.setToast("extract failed: "+msg.Err.Error(), true) + } else { + m.setToast("extracted "+msg.Request.Protocol.Name+" from "+msg.Request.PcapFile, false) + } + return m, nil + } + + var cmd tea.Cmd + m.prompt, cmd = m.prompt.Update(msg) + return m, cmd +} + +// resize recomputes the layout and hands the new width to everything that needs it. +func (m Model) resize(size tui.Size) Model { + m.size = size + m.layout = tui.Compute(size) + m.prompt.SetWidth(size.Width) + m.footer.SetWidth(size.Width) + // Leaving compact mode closes an overlay that only exists there, so that the pane does not + // stay stuck over a layout that has room for it as a column. + if m.layout.Mode == tui.ModeWide && m.overlay != paneCount { + m.pane = m.overlay + m.overlay = paneCount + } + // A resize can take the column out from under the focused pane — dragging a window from + // wide to compact while the detail pane has the keyboard used to leave the focus on a pane + // the renderer no longer draws, so nothing looked focused and up, down and enter went + // nowhere. The keyboard follows the layout back to a pane that is actually on screen. + if m.overlay == paneCount && !m.paneVisible(m.pane) { + m.pane = paneMain + } + return m +} + +// setFocus moves the keyboard and re-resolves the keymap for wherever it landed. +func (m *Model) setFocus(focus tui.Focus) tea.Cmd { + m.focus = focus + m.keys = tui.NewKeyMap().ForFocus(focus) + m.tools = toolKeysFor(focus) + if focus == tui.FocusPrompt { + return m.prompt.Focus() + } + m.prompt.Blur() + return nil +} + +// setToast shows a one-line notice above the prompt. +func (m *Model) setToast(text string, isError bool) { + m.toast = text + m.toastErr = isError +} + +// appendLog adds lines to the log pane, keeping it bounded. +func (m *Model) appendLog(lines ...string) { + for _, line := range lines { + if line == "" { + continue + } + m.logLines = append(m.logLines, line) + } + limit := m.state.Config.MaxConsoleLines + if limit < 1 { + limit = 500 + } + if len(m.logLines) > limit { + m.logLines = append([]string(nil), m.logLines[len(m.logLines)-limit:]...) + } +} + +// runLine executes a command line and applies its outcome. +// +// Commands run here, on the event loop, because they change State and State belongs to the +// model. The only work that leaves the loop is an analysis or an extraction, and those are +// started as commands that return a message rather than reaching back into the model. +func (m Model) runLine(line string) (tea.Model, tea.Cmd) { + m.appendLog("$ " + line) + m.state.Config.RememberCommand(line) + + outcome := m.root.Execute(context.Background(), m.state, line) + m.appendLog(outcome.Lines...) + + if outcome.Err != nil { + m.setToast(outcome.Err.Error(), true) + m.appendLog("error: " + outcome.Err.Error()) + m.tab = tabLog + return m, nil + } + m.setToast("", false) + + switch { + case outcome.Quit: + return m.quit() + case outcome.Abort: + return m.abortRun(), nil + case outcome.Analysis != nil: + return m.startAnalysis(*outcome.Analysis) + case outcome.Extraction != nil: + return m.startExtraction(*outcome.Extraction) + } + if outcome.ClearPackets { + m.packets, m.visible, m.cursor = nil, nil, 0 + } + if outcome.ClearLog { + m.logLines, m.logTop = nil, 0 + } + if outcome.ClearTranscript { + // The transcript and the log share one pane, so clearing either empties it. Saying so + // is better than silently doing nothing to a command the user typed. + m.logLines, m.logTop = nil, 0 + } + return m, nil +} + +// startAnalysis begins a run. +func (m Model) startAnalysis(request Request) (tea.Model, tea.Cmd) { + if m.run.active { + m.setToast("a run is already in flight, esc aborts it", true) + return m, nil + } + ctx, cancel := context.WithCancel(context.Background()) + m.cancelRun = cancel + m.packets, m.visible, m.cursor = nil, nil, 0 + m.run = runState{ + kind: runKindAnalysis, + label: request.Label(), + phase: PhaseIndex, + active: true, + started: m.now(), + } + m.tab = tabPackets + m.prompt.Start() + + analyze := m.analyze + progressCh, recordCh := m.progressCh, m.recordCh + run := func() tea.Msg { + result := analyze(ctx, request, Sink{ + Progress: progress.NewChannel(progressCh), + Observe: func(record Record) { + // The only thing this goroutine may do with a record is hand it over. A + // blocking send is safe because the drain re-arms itself, and the context is + // cancelled when the program quits. + select { + case recordCh <- record: + case <-ctx.Done(): + } + }, + }) + return analysisDoneMsg{Result: result} + } + return m, tea.Batch(run, tui.PromptTick()) +} + +// finishAnalysis applies the authoritative outcome of a run. +func (m Model) finishAnalysis(result Result) (tea.Model, tea.Cmd) { + m.run.active = false + m.run.aborted = result.Aborted + m.run.aborting = false + m.run.err = result.Err + m.run.counters = result.Counters + m.run.elapsed = result.Elapsed + m.run.phase = PhaseAnalyze + m.run.current, m.run.total = result.Counters.Walked, result.Counters.Walked + m.prompt.Finish() + m.cancelRun = nil + + // The streamed records are replaced rather than appended to: the batches and the done + // message travel by different routes, so the result is the only ordering that can be + // trusted. + m.packets = result.Records + m.state.RecordAnalysis(result) + m.refilter() + if m.follow { + m.cursor = max(len(m.visible)-1, 0) + m = m.revealCursor() + } + + switch { + case result.Err != nil: + m.setToast("analyze failed: "+result.Err.Error(), true) + m.appendLog("error: " + result.Err.Error()) + m.tab = tabLog + case result.Aborted: + m.setToast("aborted after "+itoa(result.Counters.Walked)+" packets", false) + default: + m.setToast(summarise(result.Counters), result.Counters.Issues() > 0) + } + return m, nil +} + +// startExtraction begins an extraction. +func (m Model) startExtraction(request Request) (tea.Model, tea.Cmd) { + if m.run.active { + m.setToast("a run is already in flight, esc aborts it", true) + return m, nil + } + ctx, cancel := context.WithCancel(context.Background()) + m.cancelRun = cancel + m.run = runState{ + kind: runKindExtraction, + label: "extract " + request.Protocol.Name + " " + baseName(request.PcapFile), + phase: PhaseAnalyze, + active: true, + started: m.now(), + } + m.tab = tabLog + m.prompt.Start() + + extract := m.extract + progressCh, logCh := m.progressCh, m.logCh + run := func() tea.Msg { + err := extract(ctx, request, progress.NewChannel(progressCh), func(line string) { + select { + case logCh <- line: + default: + // The log is a courtesy: an extraction that outruns the display drops lines + // rather than being throttled by it. + } + }) + return extractionDoneMsg{Request: request, Err: err} + } + return m, tea.Batch(run, tui.PromptTick()) +} + +// abortRun cancels whatever is in flight. +func (m Model) abortRun() Model { + if m.cancelRun != nil { + m.cancelRun() + m.run.aborting = true + m.setToast("aborting", false) + } else { + m.setToast("nothing to abort", false) + } + return m +} + +// lineWriterFunc receives one line of output. +type lineWriterFunc func(line string) + +// runExtraction is the real extraction, wired to the shared extractor. +// +// This is where progress.NewChannel meets extractor.Options: the extraction publishes values +// and the model turns them into messages, so nothing in the extractor ever touches the model. +func runExtraction(ctx context.Context, request Request, reporter progress.Reporter, out lineWriterFunc) error { + writer := newLineWriter(out) + defer writer.Flush() + // A theme with colour switched off: the output goes into the log pane, which applies the + // UI's own styling, and escape sequences arriving from underneath would fight it. + theme := tui.NewTheme(tui.Options{Dark: true, NoColor: true, ASCII: true}) + return extractor.ExtractWithOptions(ctx, request.PcapFile, request.Protocol.Name, extractor.Options{ + Stdout: writer, + Stderr: writer, + Progress: reporter, + Theme: &theme, + }) +} + +// waitForProgress reads ONE progress frame and returns it as a message, re-arming itself. +// +// This is the whole concurrency contract of the program: an analysis running on its own +// goroutine may publish values on a channel and nothing else. The version this replaces called +// application.QueueUpdateDraw from inside the analysis and mutated widgets there. +func waitForProgress(ch chan progress.Update) tea.Cmd { + return func() tea.Msg { + update, ok := <-ch + if !ok { + return nil + } + return progressMsg{Update: update} + } +} + +// waitForLog reads one log line, then takes whatever else is already queued, so that a burst +// of logging costs one turn of the event loop rather than one per line. +func waitForLog(ch chan string) tea.Cmd { + return func() tea.Msg { + line, ok := <-ch + if !ok { + return nil + } + lines := []string{line} + for len(lines) < recordBatch { + select { + case next, ok := <-ch: + if !ok { + return logMsg{Lines: lines} + } + lines = append(lines, next) + default: + return logMsg{Lines: lines} + } + } + return logMsg{Lines: lines} + } +} + +// waitForRecords reads one record and then drains what is already queued behind it. +func waitForRecords(ch chan Record) tea.Cmd { + return func() tea.Msg { + record, ok := <-ch + if !ok { + return nil + } + records := []Record{record} + for len(records) < recordBatch { + select { + case next, ok := <-ch: + if !ok { + return recordsMsg{Records: records} + } + records = append(records, next) + default: + return recordsMsg{Records: records} + } + } + return recordsMsg{Records: records} + } +} + +// fold adds one record to a set of counters, the same way Run does. +func fold(counters Counters, record Record) Counters { + counters.Walked++ + switch record.Verdict { + case VerdictParseFail: + counters.ParseFail++ + case VerdictSerializeFail: + counters.Parsed++ + counters.SerializeFail++ + case VerdictBytesDiffer: + counters.Parsed++ + counters.CompareFail++ + case VerdictOK: + counters.Parsed++ + default: + counters.Skipped++ + } + return counters +} + +// summarise is the toast a finished run shows. +func summarise(counters Counters) string { + if counters.Issues() == 0 { + return "analysed " + itoa(counters.Walked) + " packets, no issues" + } + return "analysed " + itoa(counters.Walked) + " packets, " + itoa(counters.Issues()) + " issues" +} + +// refilter recomputes which packets the table shows and keeps the cursor in range. +func (m *Model) refilter() { + m.visible = m.visible[:0] + needle := strings.ToLower(strings.TrimSpace(m.filter)) + for i, record := range m.packets { + if m.tab == tabFindings && !record.Verdict.IsIssue() { + continue + } + if needle != "" && !recordMatches(record, needle) { + continue + } + m.visible = append(m.visible, i) + } + if m.cursor >= len(m.visible) { + m.cursor = max(len(m.visible)-1, 0) + } + if m.cursor < 0 { + m.cursor = 0 + } + // Follow, filter and a fresh result all move the cursor without a keypress. The window has + // to keep up, or the stored offset says one thing while the renderer clamps to another. + *m = m.revealCursor() +} + +// recordMatches reports whether a record satisfies the table filter. +// +// bubbles/v2/table has no filtering of any kind, so this is ours. The full slice is kept and a +// display slice is derived from it, which is also what keeps the "n of m" counter honest. +func recordMatches(record Record, needle string) bool { + for _, field := range []string{ + itoa(record.Number), + record.Protocol, + record.Summary, + record.Verdict.String(), + record.Reason, + } { + if strings.Contains(strings.ToLower(field), needle) { + return true + } + } + return false +} + +// selectedRecord is the record the cursor is on. +func (m Model) selectedRecord() (Record, bool) { + if m.cursor < 0 || m.cursor >= len(m.visible) { + return Record{}, false + } + return m.packets[m.visible[m.cursor]], true +} + +// findings are the records that represent a defect. +func (m Model) findings() []Record { + var out []Record + for _, record := range m.packets { + if record.Verdict.IsIssue() { + out = append(out, record) + } + } + return out +} + +// toolKeys are the bindings this tool adds to the shared set. They are all bare characters and +// therefore all pane-scoped: at the prompt they must be the letters they look like. +type toolKeys struct { + TabNext key.Binding + TabPrev key.Binding + ViewBytes key.Binding + ViewTree key.Binding + ViewDiff key.Binding + Open key.Binding + Analyze key.Binding + Extract key.Binding +} + +// toolKeysFor resolves the tool bindings for a focus, through the shared Scoped helper so they +// get exactly the same treatment as the shared ones. +func toolKeysFor(focus tui.Focus) toolKeys { + scoped := func(keys, label, description string) key.Binding { + return tui.Scoped( + key.NewBinding(key.WithKeys(strings.Split(keys, ",")...), key.WithHelp(label, description)), + tui.ScopePane, focus) + } + return toolKeys{ + TabNext: scoped("]", "]", "next tab"), + TabPrev: scoped("[", "[", "prev tab"), + ViewBytes: scoped("b", "b", "bytes"), + ViewTree: scoped("t", "t", "tree"), + ViewDiff: scoped("d", "d", "diff"), + Open: scoped("o", "o", "open"), + Analyze: scoped("a", "a", "analyze"), + Extract: scoped("x", "x", "extract"), + } +} + +// shortHelp is the tool bindings worth a place in the one-line footer. +// +// Only two: the footer drops bindings from the tail to fit, so anything added here competes +// with the shared ones, and the tab pair is what a user needs to find the findings list. +func (t toolKeys) shortHelp() []key.Binding { + return []key.Binding{t.TabNext, t.Analyze} +} + +// fullHelp is every tool binding, grouped as the expanded help columns. +func (t toolKeys) fullHelp() [][]key.Binding { + return [][]key.Binding{ + {t.TabNext, t.TabPrev}, + {t.ViewBytes, t.ViewTree, t.ViewDiff}, + {t.Open, t.Analyze, t.Extract}, + } +} + +// baseName is the file name part of a path, for a label that has to fit in a border. +func baseName(path string) string { + if index := strings.LastIndexAny(path, `/\`); index >= 0 { + return path[index+1:] + } + return path +} diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/model_test.go b/plc4go/tools/plc4xpcapanalyzer/ui/model_test.go new file mode 100644 index 000000000..eca071bc9 --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/ui/model_test.go @@ -0,0 +1,898 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package ui + +import ( + "context" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/progress" + "github.com/apache/plc4x-extras/plc4go/tools/internal/tui" +) + +// These drive Update with typed messages. No terminal is involved and nothing sleeps. + +// wide and compact are the two sizes the model behaves differently at. +var ( + wide = tui.Size{Width: 100, Height: 30} + compact = tui.Size{Width: 80, Height: 24} + minimal = tui.Size{Width: 60, Height: 12} +) + +// fakeResult builds an analysis outcome without reading a capture, for the tests that are +// about the model rather than about the analysis. +func fakeResult(request Request, records ...Record) Result { + result := Result{Request: request, Records: records} + for _, record := range records { + result.Counters = fold(result.Counters, record) + } + return result +} + +// okRecord and brokenRecord are the two kinds of row the table has to draw. +func okRecord(number int) Record { + return Record{ + Number: number, + Offset: time.Duration(number) * time.Millisecond, + Direction: DirectionRequest, + Protocol: "c-bus", + Summary: "CBusMessageToServer", + Verdict: VerdictOK, + Original: []byte("~~~\r"), + Reserialized: []byte("~~~\r"), + Tree: "CBusMessageToServer\n header\n request", + DiffOffset: -1, + } +} + +func brokenRecord(number int) Record { + return Record{ + Number: number, + Offset: time.Duration(number) * time.Millisecond, + Direction: DirectionResponse, + Protocol: "c-bus", + Summary: "DirectCommandAccess", + Verdict: VerdictBytesDiffer, + Reason: "4 bytes differ", + Original: []byte("\\3138000000AA3A\r"), + Reserialized: []byte("\\30380000\r"), + DiffOffset: 2, + } +} + +// analysed returns a model at wide size with a finished run in it. +func analysed(t *testing.T, records ...Record) Model { + t.Helper() + state, demo := demoState(t) + model := newTestModel(t, state, wide) + request := state.RequestFor(demo.Protocol, demo.Path) + model = send(model, startAnalysisMsg{Request: request}) + return send(model, analysisDoneMsg{Result: fakeResult(request, records...)}) +} + +func TestWindowSizeResolvesTheLayout(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, wide) + assert.Equal(t, tui.ModeWide, model.layout.Mode) + + model = send(model, tea.WindowSizeMsg{Width: compact.Width, Height: compact.Height}) + assert.Equal(t, tui.ModeCompact, model.layout.Mode) + + model = send(model, tea.WindowSizeMsg{Width: 40, Height: 10}) + assert.Equal(t, tui.ModeTooSmall, model.layout.Mode) +} + +func TestBackgroundColourRebuildsTheTheme(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, wide) + require.True(t, model.theme.IsDark()) + + model = send(model, tea.BackgroundColorMsg{Color: lightBackground{}}) + assert.False(t, model.theme.IsDark(), "a light terminal must get the light palette") + + model = send(model, tea.BackgroundColorMsg{Color: darkBackground{}}) + assert.True(t, model.theme.IsDark()) +} + +// lightBackground and darkBackground stand in for what a terminal reports. +type lightBackground struct{} + +func (lightBackground) RGBA() (r, g, b, a uint32) { return 0xffff, 0xffff, 0xffff, 0xffff } + +type darkBackground struct{} + +func (darkBackground) RGBA() (r, g, b, a uint32) { return 0, 0, 0, 0xffff } + +// TestBareKeysAreInertAtThePrompt is the rule the whole scoping mechanism exists for: typing +// the word "analyze" must type it, not fire the a, n, l, y, z and e pane actions. +func TestBareKeysAreInertAtThePrompt(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, wide) + require.Equal(t, tui.FocusPrompt, model.focus) + + for _, line := range []string{"analyze", "extract", "quit", "log get", "wify"} { + typed := model + for _, r := range line { + typed = send(typed, charKey(r)) + } + assert.Equal(t, line, typed.prompt.Value(), "typing %q must put %q in the prompt", line, line) + assert.Equal(t, tui.FocusPrompt, typed.focus, "typing %q must not move the keyboard", line) + assert.False(t, typed.quitting, "typing %q must not quit", line) + assert.Equal(t, tabPackets, typed.tab, "typing %q must not switch tabs", line) + } +} + +// TestQuitBindingsQuit covers both ways out, and that each of them asks first: q and ctrl+c +// are easy to hit by accident, and the answer to a mistaken one should not be a closed capture. +func TestQuitBindingsQuit(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, wide) + + asked, cmd := sendWithCmd(model, tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + require.True(t, asked.confirmQuit, "ctrl+c asks before ending the session") + assert.False(t, asked.quitting, "and does not end it until answered") + assert.Nil(t, cmd) + + quitted, cmd := sendWithCmd(asked, tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + assert.True(t, quitted.quitting, "a second ctrl+c confirms") + require.NotNil(t, cmd) + + panes := send(model, modKey(tea.KeyTab, tea.ModShift)) + require.Equal(t, tui.FocusPane, panes.focus) + asked = send(panes, charKey('q')) + require.True(t, asked.confirmQuit, "q asks too, while a pane has the keyboard") + quitted = send(asked, charKey('y')) + assert.True(t, quitted.quitting, "y confirms") +} + +// TestAnyOtherKeyKeepsTheSession is the point of asking: the question has to be refusable, and +// the key that refuses it must not also do whatever it would normally have done. +func TestAnyOtherKeyKeepsTheSession(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, wide) + + for _, refusal := range []tea.KeyPressMsg{ + namedKey(tea.KeyEscape), + charKey('n'), + charKey('a'), + charKey('/'), + modKey(tea.KeyTab, tea.ModShift), + } { + asked := send(model, tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + require.True(t, asked.confirmQuit) + + stayed := send(asked, refusal) + assert.False(t, stayed.quitting, "%s must not quit", refusal.String()) + assert.False(t, stayed.confirmQuit, "%s takes the question down", refusal.String()) + // Swallowed, not passed on: 'a' would have started an analysis and '/' a filter. + assert.False(t, stayed.filtering, "%s must not also do its usual job", refusal.String()) + assert.False(t, stayed.run.active, "%s must not also do its usual job", refusal.String()) + assert.Equal(t, model.focus, stayed.focus, "%s must not also move the keyboard", refusal.String()) + } +} + +func TestShiftTabMovesToThePanesAndBackToThePrompt(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, wide) + + model = send(model, modKey(tea.KeyTab, tea.ModShift)) + assert.Equal(t, tui.FocusPane, model.focus) + + model = send(model, charKey(':')) + assert.Equal(t, tui.FocusPrompt, model.focus, "one binding always leads back to the prompt") +} + +func TestTabCyclesTheVisiblePanesOnly(t *testing.T) { + model := analysed(t, okRecord(1)) + model = send(model, modKey(tea.KeyTab, tea.ModShift)) + require.Equal(t, tui.FocusPane, model.focus) + + visited := []pane{model.pane} + for range len(model.visiblePanes()) { + model = send(model, namedKey(tea.KeyTab)) + visited = append(visited, model.pane) + } + for _, visited := range visited { + assert.True(t, model.paneVisible(visited), "tab landed on %s, which the layout does not show", visited) + } + assert.Equal(t, visited[0], visited[len(visited)-1], "tab must cycle back round") +} + +func TestPaneJumpOpensAnOverlayForAPaneTheLayoutHasNoRoomFor(t *testing.T) { + model := analysed(t, okRecord(1)) + model = send(model, tea.WindowSizeMsg{Width: compact.Width, Height: compact.Height}) + require.Equal(t, tui.ModeCompact, model.layout.Mode) + require.False(t, model.paneVisible(paneCaptures)) + + model = send(model, modKey('1', tea.ModAlt)) + assert.Equal(t, paneCaptures, model.overlay, "alt+1 must not be a dead key just because there is no column") + assert.Equal(t, paneCaptures, model.activePane()) + + model = send(model, namedKey(tea.KeyEscape)) + assert.Equal(t, paneCount, model.overlay, "esc closes the overlay") +} + +func TestLeavingCompactModeClosesTheOverlay(t *testing.T) { + model := analysed(t, okRecord(1)) + model = send(model, tea.WindowSizeMsg{Width: compact.Width, Height: compact.Height}) + model = send(model, modKey('3', tea.ModAlt)) + require.Equal(t, paneDetail, model.overlay) + + model = send(model, tea.WindowSizeMsg{Width: wide.Width, Height: wide.Height}) + assert.Equal(t, paneCount, model.overlay, "a pane that has a column again must not stay stuck as an overlay") + assert.Equal(t, paneDetail, model.pane, "the focus follows it into the column") +} + +// TestAResizeNeverLeavesTheKeyboardOnAnUndrawnPane is the other half of +// TestNoPaneIsFocusableWithoutBeingDrawn, which only pins that paneVisible agrees with the +// geometry. Dragging a window from wide to compact with the detail pane focused took its +// column away and left the focus on it: nothing rendered as focused and up, down and enter all +// went somewhere the user could not see. +func TestAResizeNeverLeavesTheKeyboardOnAnUndrawnPane(t *testing.T) { + model := analysed(t, okRecord(1), okRecord(2)) + model = send(model, modKey(tea.KeyTab, tea.ModShift)) + model = send(model, modKey('3', tea.ModAlt)) + require.Equal(t, paneDetail, model.pane) + require.True(t, model.paneVisible(paneDetail)) + + for _, size := range []tui.Size{compact, minimal, {Width: 90, Height: 14}, wide} { + t.Run(size.String(), func(t *testing.T) { + resized := send(model, tea.WindowSizeMsg{Width: size.Width, Height: size.Height}) + assert.True(t, resized.paneVisible(resized.pane), + "the keyboard is on %s, which the layout at %s does not draw", resized.pane, size) + }) + } +} + +func TestBracketsSwitchTheTabs(t *testing.T) { + model := analysed(t, okRecord(1), brokenRecord(2)) + model = send(model, modKey(tea.KeyTab, tea.ModShift)) + require.Equal(t, tabPackets, model.tab) + + model = send(model, charKey(']')) + assert.Equal(t, tabLog, model.tab) + model = send(model, charKey(']')) + assert.Equal(t, tabFindings, model.tab) + assert.Len(t, model.visible, 1, "the findings tab shows only the records that are defects") + + model = send(model, charKey('[')) + assert.Equal(t, tabLog, model.tab) +} + +func TestDetailViewKeys(t *testing.T) { + model := analysed(t, brokenRecord(1)) + model = send(model, modKey(tea.KeyTab, tea.ModShift)) + require.Equal(t, detailDiff, model.detail, "the byte comparison is the default because it is the point of the tool") + + assert.Equal(t, detailBytes, send(model, charKey('b')).detail) + assert.Equal(t, detailTree, send(model, charKey('t')).detail) + assert.Equal(t, detailDiff, send(send(model, charKey('t')), charKey('d')).detail) +} + +// TestTheDetailPaneCannotBeScrolledPastItsContent pins the clamp. The offset used to be held +// against an arbitrary ceiling, so a held-down cursor key ran it into the thousands: the pane +// still drew its last line, but getting back to the top took one press for every press spent +// overshooting, and in the diff view — which does not scroll at all — the overshoot was +// invisible until the bytes view inherited it. +func TestTheDetailPaneCannotBeScrolledPastItsContent(t *testing.T) { + model := analysed(t, brokenRecord(1)) + model = send(model, modKey(tea.KeyTab, tea.ModShift)) + model = send(model, modKey('3', tea.ModAlt)) + + diff := model + for range 40 { + diff = send(diff, charKey('j')) + } + assert.Zero(t, diff.detailTop, "the diff view draws a fixed block, so there is nowhere to scroll to") + + bytesView := send(model, charKey('b')) + rows := len(HexDumpLines(brokenRecord(1).Original, hexBytesPerRow, 1<<16)) + for range 40 { + bytesView = send(bytesView, charKey('j')) + } + assert.Equal(t, rows-1, bytesView.detailTop, "the hex dump stops at its last row") + + // And one press of up has to move, rather than paying off an overshoot. + assert.Equal(t, rows-2, send(bytesView, charKey('k')).detailTop) +} + +// TestAPageIsTheHeightOfTheDrawnPane keeps the page keys measured against what is on screen. +// tui.Layout.BodyHeight reserves rows for a log drawer, and this tool draws the log as a tab +// of the main pane instead, so taking the page size from there scrolled short. +func TestAPageIsTheHeightOfTheDrawnPane(t *testing.T) { + model := analysed(t, okRecord(1)) + for _, size := range sizes { + t.Run(size.String(), func(t *testing.T) { + sized := send(model, tea.WindowSizeMsg{Width: size.Width, Height: size.Height}) + geometry := sized.geometry() + mainHeight := geometry.PaneHeight - geometry.DetailHeight + assert.LessOrEqual(t, sized.pageSize(), geometry.PaneHeight, + "a page must not be taller than the pane area it scrolls") + assert.GreaterOrEqual(t, sized.pageSize(), mainHeight-2, + "nor shorter than the rows the main pane actually draws") + }) + } +} + +// TestQuitCancelsAnInFlightRun keeps the analysis goroutine from outliving the program. It +// publishes each record with a blocking send guarded only by its own context, so quitting +// without cancelling parked it on a send nothing would ever drain. +func TestQuitCancelsAnInFlightRun(t *testing.T) { + state, demo := demoState(t) + model := newTestModel(t, state, wide) + observed := make(chan error, 1) + model.analyze = func(ctx context.Context, request Request, _ Sink) Result { + <-ctx.Done() + observed <- ctx.Err() + return Result{Request: request, Aborted: true} + } + model, cmd := sendWithCmd(model, startAnalysisMsg{Request: state.RequestFor(demo.Protocol, demo.Path)}) + require.True(t, model.run.active) + + // The run is one member of a batch, and calling a batched command only hands its members + // back. Starting them here is what actually puts the analysis on its own goroutine. + batch, ok := cmd().(tea.BatchMsg) + require.True(t, ok, "startAnalysis has to batch the run with the spinner tick") + for _, batched := range batch { + go func() { batched() }() + } + + // ctrl+c with a run in flight stops the run rather than the session: that is what the + // keystroke is for, and ending the session would throw away the records already collected. + model = send(model, tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + require.False(t, model.quitting, "ctrl+c during a run aborts the run, not the session") + require.False(t, model.confirmQuit, "and does not ask to quit either") + + select { + case err := <-observed: + assert.ErrorIs(t, err, context.Canceled) + case <-time.After(3 * time.Second): + t.Fatal("ctrl+c left the analysis running") + } +} + +func TestProgressAdvancesTheBar(t *testing.T) { + state, demo := demoState(t) + model := newTestModel(t, state, wide) + request := state.RequestFor(demo.Protocol, demo.Path) + model = send(model, startAnalysisMsg{Request: request}) + require.True(t, model.run.active) + + model = send(model, progressMsg{Update: progress.Update{Current: 0, Total: 0, Description: PhaseIndex}}) + assert.Equal(t, PhaseIndex, model.run.phase) + + model = send(model, progressMsg{Update: progress.Update{Current: 30, Total: 120, Description: PhaseAnalyze}}) + assert.Equal(t, PhaseAnalyze, model.run.phase) + assert.Equal(t, 30, model.run.current) + assert.Equal(t, 120, model.run.total) + assert.Contains(t, model.Render(), "25%", "the bar has to show the fraction it was given") +} + +// TestProgressReArmsItself is the whole streaming contract: one value read, one message +// returned, and the command re-issued. Losing the re-arm freezes the bar after one frame. +func TestProgressReArmsItself(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, wide) + + _, cmd := sendWithCmd(model, progressMsg{Update: progress.Update{Current: 1, Total: 2}}) + require.NotNil(t, cmd, "handling a progress frame must re-arm the drain") + + model.progressCh <- progress.Update{Current: 2, Total: 2} + msg := cmd() + assert.Equal(t, progressMsg{Update: progress.Update{Current: 2, Total: 2}}, msg) +} + +func TestRecordsStreamIntoTheTableWhileTheRunIsInFlight(t *testing.T) { + state, demo := demoState(t) + model := newTestModel(t, state, wide) + request := state.RequestFor(demo.Protocol, demo.Path) + model = send(model, startAnalysisMsg{Request: request}) + + model = send(model, recordsMsg{Records: []Record{okRecord(1), brokenRecord(2)}}) + assert.Len(t, model.packets, 2) + assert.Len(t, model.visible, 2) + assert.Equal(t, 1, model.run.counters.CompareFail, "the counters have to move while the run is in flight") + assert.Equal(t, 2, model.run.counters.Parsed, + "a packet that parsed and then failed the byte comparison still parsed") +} + +func TestRecordsArriveAfterTheRunAreIgnored(t *testing.T) { + model := analysed(t, okRecord(1)) + require.False(t, model.run.active) + + model = send(model, recordsMsg{Records: []Record{okRecord(99)}}) + assert.Len(t, model.packets, 1, "a batch that overtook the result must not double the table") +} + +func TestAnalysisDoneReplacesTheTableWithTheAuthoritativeRecords(t *testing.T) { + state, demo := demoState(t) + model := newTestModel(t, state, wide) + request := state.RequestFor(demo.Protocol, demo.Path) + model = send(model, startAnalysisMsg{Request: request}) + model = send(model, recordsMsg{Records: []Record{okRecord(1)}}) + + result := fakeResult(request, okRecord(1), brokenRecord(2), okRecord(3)) + model = send(model, analysisDoneMsg{Result: result}) + + assert.Equal(t, result.Records, model.packets) + assert.Equal(t, result.Counters, model.run.counters) + assert.False(t, model.run.active) + assert.False(t, model.prompt.Busy(), "the prompt's in-flight indication has to stop with the run") + assert.Contains(t, model.toast, "1 issues") + + capture, ok := state.CurrentCapture() + require.True(t, ok) + assert.Equal(t, 3, capture.Packets, "the sidebar's packet count comes from the finished run") + assert.Equal(t, 1, capture.Issues) +} + +func TestAnalysisFailureIsReportedRatherThanSwallowed(t *testing.T) { + state, demo := demoState(t) + model := newTestModel(t, state, wide) + request := state.RequestFor(demo.Protocol, demo.Path) + model = send(model, startAnalysisMsg{Request: request}) + + result := Result{Request: request, Err: assertError{}} + model = send(model, analysisDoneMsg{Result: result}) + + assert.True(t, model.toastErr) + assert.Contains(t, model.toast, "analyze failed") + assert.Equal(t, tabLog, model.tab, "a failure moves the user to where the detail is") + assert.Contains(t, strings.Join(model.logLines, "\n"), "boom") +} + +// assertError is a stable error for the tests above. +type assertError struct{} + +func (assertError) Error() string { return "boom" } + +func TestEscapeAbortsARunningAnalysis(t *testing.T) { + state, demo := demoState(t) + model := newTestModel(t, state, wide) + model.analyze = func(ctx context.Context, request Request, _ Sink) Result { + <-ctx.Done() + return Result{Request: request, Aborted: true} + } + request := state.RequestFor(demo.Protocol, demo.Path) + model, cmd := sendWithCmd(model, startAnalysisMsg{Request: request}) + require.True(t, model.run.active) + require.NotNil(t, cmd) + + // esc has nothing more local to dismiss, so it reaches the run. + model = send(model, namedKey(tea.KeyEscape)) + assert.Contains(t, model.toast, "abort") + + // The batched command holds the analysis, which is parked on its cancelled context. It has + // to come back, or esc would only ever look as though it aborted something. + // + // The run has to be pulled out of the batch to be started. Calling the batched command + // itself only hands its members back — it never enters the analysis — so waiting on that + // return proved nothing at all. + batch, ok := cmd().(tea.BatchMsg) + require.True(t, ok) + done := make(chan tea.Msg, len(batch)) + for _, batched := range batch { + go func() { done <- batched() }() + } + deadline := time.After(3 * time.Second) + for range batch { + select { + case <-done: + case <-deadline: + t.Fatal("the cancelled analysis did not return") + } + } +} + +// TestEscapeChainTakesOneLinkAtATime pins that a single press never does two things. +func TestEscapeChainTakesOneLinkAtATime(t *testing.T) { + model := analysed(t, okRecord(1)) + model = send(model, tea.WindowSizeMsg{Width: compact.Width, Height: compact.Height}) + model = send(model, modKey('1', tea.ModAlt)) + require.Equal(t, paneCaptures, model.overlay) + require.NotEmpty(t, model.toast) + + model = send(model, namedKey(tea.KeyEscape)) + assert.Equal(t, paneCount, model.overlay, "the first press closes the overlay") + assert.NotEmpty(t, model.toast, "and leaves the toast alone") + + model = send(model, namedKey(tea.KeyEscape)) + assert.Empty(t, model.toast, "the second press dismisses the toast") + + model = send(model, namedKey(tea.KeyEscape)) + assert.Equal(t, tui.FocusPrompt, model.focus, "and the last press returns the keyboard to the prompt") +} + +func TestFilterNarrowsTheTableAndIsNotAPaneShortcut(t *testing.T) { + model := analysed(t, okRecord(1), brokenRecord(2)) + model = send(model, modKey(tea.KeyTab, tea.ModShift)) + model = send(model, charKey('/')) + require.True(t, model.filtering) + assert.Equal(t, tui.FocusOverlay, model.focus, "typing a filter is text entry, so the bare pane keys go dead") + + for _, r := range "bytes" { + model = send(model, charKey(r)) + } + assert.Equal(t, "bytes", model.filter) + assert.Len(t, model.visible, 1, "only the record whose verdict is bytes survives") + + model = send(model, namedKey(tea.KeyEnter)) + assert.False(t, model.filtering) + assert.Equal(t, tui.FocusPane, model.focus) + assert.Contains(t, model.Render(), "1 of 2", "the counter has to say how much was hidden") + + model = send(model, charKey('/')) + model = send(model, namedKey(tea.KeyEscape)) + assert.Empty(t, model.filter, "esc clears the filter rather than leaving the table narrowed") + assert.Len(t, model.visible, 2) +} + +// TestQuitWorksWhileTypingAFilter is the rule that the way out is never captured by a mode. +func TestQuitWorksWhileTypingAFilter(t *testing.T) { + model := analysed(t, okRecord(1)) + model = send(model, modKey(tea.KeyTab, tea.ModShift)) + model = send(model, charKey('/')) + require.True(t, model.filtering) + + model = send(model, tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + require.True(t, model.confirmQuit, "ctrl+c has to work even while a mode owns the keyboard") + model = send(model, charKey('y')) + assert.True(t, model.quitting) +} + +func TestSelectingAPacketMovesToTheDetail(t *testing.T) { + model := analysed(t, okRecord(1)) + model = send(model, modKey('2', tea.ModAlt)) + require.Equal(t, paneMain, model.activePane()) + + wideSelection := send(model, namedKey(tea.KeyEnter)) + assert.Equal(t, paneDetail, wideSelection.pane, "where there is a detail column, enter goes to it") + + narrow := send(model, tea.WindowSizeMsg{Width: compact.Width, Height: compact.Height}) + narrow = send(narrow, modKey('2', tea.ModAlt)) + narrowSelection := send(narrow, namedKey(tea.KeyEnter)) + assert.Equal(t, paneDetail, narrowSelection.overlay, "where there is not, enter opens it as an overlay") +} + +func TestFilterBackspace(t *testing.T) { + model := analysed(t, okRecord(1), brokenRecord(2)) + model = send(model, modKey(tea.KeyTab, tea.ModShift)) + model = send(model, charKey('/')) + for _, r := range "byt" { + model = send(model, charKey(r)) + } + model = send(model, namedKey(tea.KeyBackspace)) + assert.Equal(t, "by", model.filter) +} + +func TestMovingTheCursorTurnsFollowOff(t *testing.T) { + model := analysed(t, okRecord(1), okRecord(2), okRecord(3)) + model = send(model, modKey(tea.KeyTab, tea.ModShift)) + require.True(t, model.follow) + + model = send(model, charKey('k')) + assert.False(t, model.follow, "taking the cursor by hand has to stop the run dragging it away") + + model = send(model, charKey('f')) + assert.True(t, model.follow) +} + +func TestSelectingASidebarDriverRegistersIt(t *testing.T) { + model := analysed(t, okRecord(1)) + model = send(model, modKey('1', tea.ModAlt)) + require.Equal(t, paneCaptures, model.activePane()) + + // Walk to the first driver row. + rows := model.sidebarRows() + selectable := model.sidebarSelectable() + index := -1 + for i, row := range selectable { + if rows[row].kind == sidebarDriver && rows[row].key == "c-bus" { + index = i + break + } + } + require.GreaterOrEqual(t, index, 0) + model.sidebarCursor = index + + model = send(model, namedKey(tea.KeyEnter)) + assert.True(t, model.state.IsRegistered("c-bus"), "selecting a driver row must actually register it") + + model = send(model, namedKey(tea.KeyEnter)) + assert.False(t, model.state.IsRegistered("c-bus"), "and selecting it again must unregister it") +} + +// TestNoSelectableSidebarRowIsInert is the guard against the defect this port set out to +// remove: the old UI's list handlers contained nothing but a TODO comment. +func TestNoSelectableSidebarRowIsInert(t *testing.T) { + model := analysed(t, okRecord(1)) + model = send(model, modKey('1', tea.ModAlt)) + + rows := model.sidebarRows() + for cursor, row := range model.sidebarSelectable() { + t.Run(rows[row].label, func(t *testing.T) { + before := model + before.sidebarCursor = cursor + after := send(before, namedKey(tea.KeyEnter)) + changed := after.toast != before.toast || + after.prompt.Value() != before.prompt.Value() || + after.focus != before.focus || + len(after.logLines) != len(before.logLines) || + after.state.Protocol.Name != before.state.Protocol.Name + assert.True(t, changed, "selecting %q did nothing at all", rows[row].label) + }) + } +} + +func TestSelectingAnOptionTogglesIt(t *testing.T) { + model := analysed(t, okRecord(1)) + model = send(model, modKey('1', tea.ModAlt)) + + rows := model.sidebarRows() + for cursor, row := range model.sidebarSelectable() { + if rows[row].key != "only-parse" { + continue + } + model.sidebarCursor = cursor + before := analysisOptions().OnlyParse + model = send(model, namedKey(tea.KeyEnter)) + assert.NotEqual(t, before, analysisOptions().OnlyParse, "the option row has to change the option") + return + } + t.Fatal("the only-parse row is missing from the sidebar") +} + +func TestOpenAnalyzeAndExtractShortcutsPrefillThePrompt(t *testing.T) { + model := analysed(t, okRecord(1)) + model = send(model, modKey(tea.KeyTab, tea.ModShift)) + + opened := send(model, charKey('o')) + assert.Equal(t, "open ", opened.prompt.Value()) + assert.Equal(t, tui.FocusPrompt, opened.focus, "a shortcut is a head start on typing, not a hidden action") + + analyze := send(model, charKey('a')) + assert.True(t, strings.HasPrefix(analyze.prompt.Value(), "analyze c-bus ")) + assert.Contains(t, analyze.prompt.Value(), DemoCaptureName) + + extract := send(model, charKey('x')) + assert.True(t, strings.HasPrefix(extract.prompt.Value(), "extract c-bus ")) +} + +func TestSubmittingACommandRunsItAndLogsIt(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, wide) + + model = send(model, tui.PromptSubmitMsg{Line: "pwd"}) + joined := strings.Join(model.logLines, "\n") + assert.Contains(t, joined, "$ pwd") + assert.Contains(t, joined, state.CurrentDir) + assert.Contains(t, state.Config.History.Last10Commands, "pwd") +} + +func TestSubmittingAFailingCommandShowsTheError(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, wide) + + model = send(model, tui.PromptSubmitMsg{Line: "cd /definitely/not/here"}) + assert.True(t, model.toastErr) + assert.Equal(t, tabLog, model.tab) + assert.Contains(t, strings.Join(model.logLines, "\n"), "error:") +} + +func TestSubmittingQuitQuits(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, wide) + model, cmd := sendWithCmd(model, tui.PromptSubmitMsg{Line: "quit"}) + assert.True(t, model.quitting) + require.NotNil(t, cmd) + assert.Equal(t, tea.QuitMsg{}, cmd()) +} + +func TestClearEmptiesThePanes(t *testing.T) { + model := analysed(t, okRecord(1), brokenRecord(2)) + require.NotEmpty(t, model.packets) + + model = send(model, tui.PromptSubmitMsg{Line: "clear message"}) + assert.Empty(t, model.packets) + assert.Empty(t, model.visible) + + model = send(model, tui.PromptSubmitMsg{Line: "clear console"}) + assert.Empty(t, model.logLines) +} + +func TestLogLinesArriveAsMessagesAndReArmTheDrain(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, wide) + + model, cmd := sendWithCmd(model, logMsg{Lines: []string{"first", "second"}}) + assert.Equal(t, []string{"first", "second"}, model.logLines) + require.NotNil(t, cmd, "handling a log batch must re-arm the drain") + + model.logCh <- "third" + assert.Equal(t, logMsg{Lines: []string{"third"}}, cmd()) +} + +func TestTheLogIsBounded(t *testing.T) { + state, _ := demoState(t) + state.Config.MaxConsoleLines = 5 + model := newTestModel(t, state, wide) + + for i := range 20 { + model = send(model, logMsg{Lines: []string{itoa(i)}}) + } + assert.Len(t, model.logLines, 5, "a session shell that never forgets a log line is a leak") + assert.Equal(t, "19", model.logLines[4]) +} + +func TestASecondRunIsRefusedWhileOneIsInFlight(t *testing.T) { + state, demo := demoState(t) + model := newTestModel(t, state, wide) + request := state.RequestFor(demo.Protocol, demo.Path) + model = send(model, startAnalysisMsg{Request: request}) + require.True(t, model.run.active) + + model = send(model, startAnalysisMsg{Request: request}) + assert.True(t, model.toastErr) + assert.Contains(t, model.toast, "already in flight") +} + +func TestTheRunPanelOnlyAppearsOnceSomethingHasRun(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, wide) + assert.False(t, model.paneVisible(paneRun)) + assert.NotContains(t, model.Render(), "Run ") + + model = analysed(t, okRecord(1)) + assert.True(t, model.paneVisible(paneRun)) + assert.Contains(t, model.Render(), "Run ") +} + +func TestSpinnerAdvancesOnTick(t *testing.T) { + state, demo := demoState(t) + model := newTestModel(t, state, wide) + model = send(model, startAnalysisMsg{Request: state.RequestFor(demo.Protocol, demo.Path)}) + + before := model.spinnerFrame + model = send(model, tui.PromptTickMsg{Time: time.Now()}) + assert.NotEqual(t, before, model.spinnerFrame) +} + +func TestYankWritesTheDetailIntoTheLog(t *testing.T) { + model := analysed(t, brokenRecord(7)) + model = send(model, modKey(tea.KeyTab, tea.ModShift)) + model = send(model, charKey('y')) + + assert.Equal(t, tabLog, model.tab) + joined := strings.Join(model.logLines, "\n") + assert.Contains(t, joined, "packet 7") + assert.Contains(t, joined, "first difference at offset 2") +} + +func TestExpandWritesTheReasonIntoTheLog(t *testing.T) { + model := analysed(t, brokenRecord(7)) + model = send(model, modKey(tea.KeyTab, tea.ModShift)) + model = send(model, charKey('e')) + assert.Contains(t, strings.Join(model.logLines, "\n"), "4 bytes differ") +} + +func TestLogLevelKeyCyclesTheLevel(t *testing.T) { + model := analysed(t, okRecord(1)) + model = send(model, modKey(tea.KeyTab, tea.ModShift)) + before := model.state.LogLevel + + model = send(model, charKey('L')) + assert.NotEqual(t, before, model.state.LogLevel) + assert.Contains(t, model.toast, "log level") +} + +func TestWrapToggle(t *testing.T) { + model := analysed(t, okRecord(1)) + model = send(model, modKey(tea.KeyTab, tea.ModShift)) + assert.True(t, send(model, charKey('w')).detailWrap) +} + +func TestHelpKeyTogglesTheExpandedHelp(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, wide) + + model = send(model, tea.KeyPressMsg{Code: tea.KeyF1}) + assert.True(t, model.footer.ShowAll()) + model = send(model, tea.KeyPressMsg{Code: tea.KeyF1}) + assert.False(t, model.footer.ShowAll()) +} + +func TestExtractionOutcome(t *testing.T) { + state, demo := demoState(t) + model := newTestModel(t, state, wide) + model.extract = func(context.Context, Request, progress.Reporter, lineWriterFunc) error { return nil } + request := state.RequestFor(demo.Protocol, demo.Path) + + model = send(model, tui.PromptSubmitMsg{Line: "extract c-bus " + demo.Path}) + require.True(t, model.run.active) + assert.Equal(t, tabLog, model.tab, "extraction output is text, so it belongs in the log") + + model = send(model, extractionDoneMsg{Request: request}) + assert.False(t, model.run.active) + assert.False(t, model.toastErr) + assert.Contains(t, model.toast, "extracted") +} + +// TestInitArmsEveryDrain runs what Init returns rather than merely checking that it returned +// something. A batch of the wrong commands is non-nil too, and every message that reaches this +// program from off the event loop comes through one of these three drains. +func TestInitArmsEveryDrain(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, wide) + + init := model.Init() + require.NotNil(t, init) + batch, ok := init().(tea.BatchMsg) + require.True(t, ok, "Init has to batch the background-colour request with the drains") + + // A value waiting on each channel, so that the command draining it can return. + model.progressCh <- progress.Update{Current: 1, Total: 2, Description: PhaseFilter} + model.logCh <- "a line from off the loop" + model.recordCh <- okRecord(1) + + messages := make(chan tea.Msg, len(batch)) + for _, batched := range batch { + go func() { messages <- batched() }() + } + + seen := map[string]bool{} + deadline := time.After(3 * time.Second) + for range batch { + select { + case msg := <-messages: + switch msg := msg.(type) { + case progressMsg: + seen["progress"] = true + case logMsg: + seen["log"] = msg.Lines[0] == "a line from off the loop" + case recordsMsg: + seen["records"] = len(msg.Records) == 1 + } + case <-deadline: + t.Fatal("a command returned by Init never came back") + } + } + assert.True(t, seen["progress"], "the progress drain is not armed") + assert.True(t, seen["log"], "the log drain is not armed") + assert.True(t, seen["records"], "the record drain is not armed") +} + +func TestAutoRunStartsAnAnalysis(t *testing.T) { + state, demo := demoState(t) + request := state.RequestFor(demo.Protocol, demo.Path) + model := NewModel(Options{Theme: testTheme(), State: state, Clock: fixedClock(), AutoRun: &request}) + model = send(model, tea.WindowSizeMsg{Width: wide.Width, Height: wide.Height}) + + // Init queues the run as a message rather than performing it, so the bookkeeping in Update + // is the only place a run is ever started. + model = send(model, startAnalysisMsg{Request: request}) + assert.True(t, model.run.active) + assert.Equal(t, request.Label(), model.run.label) +} diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/mouse.go b/plc4go/tools/plc4xpcapanalyzer/ui/mouse.go new file mode 100644 index 000000000..b61354cae --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/ui/mouse.go @@ -0,0 +1,193 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package ui + +import ( + tea "charm.land/bubbletea/v2" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/tui" +) + +// Mouse support. +// +// The tview interface this replaces called EnableMouse(true), so clicking a pane and scrolling +// with the wheel both worked. Leaving it out would have been a silent capability regression. +// +// Bubble Tea reports coordinates rather than widgets, so the model has to be able to say which +// pane it drew at a given cell. mouseRegions is the inverse of geometry(). + +// mouseRegion is where one pane was drawn, in screen cells, borders included: clicking a +// pane's border is still clicking that pane. +type mouseRegion struct { + pane pane + x0, y0 int + x1, y1 int +} + +// contains reports whether a cell falls inside the region. +func (r mouseRegion) contains(x, y int) bool { + return x >= r.x0 && x <= r.x1 && y >= r.y0 && y <= r.y1 +} + +// mouseRegions returns where each pane sits in the current layout. +// +// It follows Render's assembly: one status row, then the body, then the run panel. The body is +// the sidebar beside a right-hand column that is the main pane over the detail pane. +func (m Model) mouseRegions() []mouseRegion { + geometry := m.geometry() + if geometry.PaneHeight <= 0 { + return nil + } + + const statusRows = 1 + bodyTop := statusRows + bodyBottom := bodyTop + geometry.PaneHeight - 1 + + var regions []mouseRegion + rightStart := 0 + if geometry.SidebarWidth > 0 { + regions = append(regions, mouseRegion{ + pane: paneCaptures, x0: 0, y0: bodyTop, + x1: geometry.SidebarWidth - 1, y1: bodyBottom, + }) + rightStart = geometry.SidebarWidth + } + + mainBottom := bodyBottom + if geometry.DetailHeight > 0 { + mainBottom = bodyBottom - geometry.DetailHeight + } + regions = append(regions, mouseRegion{ + pane: paneMain, x0: rightStart, y0: bodyTop, + x1: m.size.Width - 1, y1: mainBottom, + }) + if geometry.DetailHeight > 0 { + regions = append(regions, mouseRegion{ + pane: paneDetail, x0: rightStart, y0: mainBottom + 1, + x1: m.size.Width - 1, y1: bodyBottom, + }) + } + return regions +} + +// paneAtCell reports which pane owns a cell. +func (m Model) paneAtCell(x, y int) (pane, bool) { + for _, region := range m.mouseRegions() { + if region.contains(x, y) { + return region.pane, true + } + } + return 0, false +} + +// regionForPane returns the drawn region of a pane. +func (m Model) regionForPane(target pane) (mouseRegion, bool) { + for _, region := range m.mouseRegions() { + if region.pane == target { + return region, true + } + } + return mouseRegion{}, false +} + +// handleMouse routes a mouse event. +func (m Model) handleMouse(msg tea.MouseMsg) (Model, tea.Cmd) { + mouse := msg.Mouse() + switch msg.(type) { + case tea.MouseWheelMsg: + return m.mouseWheel(mouse), nil + case tea.MouseClickMsg: + if mouse.Button != tea.MouseLeft { + // Only the left button acts: this tool has neither a context menu nor a paste + // target, so anything else would be a surprise. + return m, nil + } + return m.mouseClick(mouse) + } + return m, nil +} + +// mouseWheel scrolls the pane under the pointer without moving focus, so the wheel can be used +// to glance at a pane without giving up the prompt. +func (m Model) mouseWheel(mouse tea.Mouse) Model { + target, ok := m.paneAtCell(mouse.X, mouse.Y) + if !ok { + return m + } + + const step = 3 + delta := step + if mouse.Button == tea.MouseWheelUp { + delta = -step + } + + // scrollPane works on the focused pane, so borrow the focus for the duration rather than + // duplicating the per-pane scrolling rules here. + restore, wasOverlay := m.pane, m.overlay + m.pane, m.overlay = target, paneCount + m = m.scroll(delta) + m.pane, m.overlay = restore, wasOverlay + return m +} + +// mouseClick gives a pane the keyboard and selects the row clicked. +func (m Model) mouseClick(mouse tea.Mouse) (Model, tea.Cmd) { + target, ok := m.paneAtCell(mouse.X, mouse.Y) + if !ok { + // A click on the prompt, the toast or the footer returns the keyboard to the prompt, + // which is the least surprising reading of a click outside the panes. + cmd := m.setFocus(tui.FocusPrompt) + return m, cmd + } + + // Focus the clicked pane the same way the pane-jump keys do, so the overlay bookkeeping + // stays in one place. + m.overlay = paneCount + m.pane = target + cmd := m.setFocus(tui.FocusPane) + + region, found := m.regionForPane(target) + if !found { + return m, cmd + } + // One row of border, and for the packet table one further row of column headings. + row := mouse.Y - region.y0 - 1 + if target == paneMain && m.tab == tabPackets && row >= 1 { + m = m.selectDrawnPacket(row - 1) + } + return m, cmd +} + +// selectDrawnPacket moves the cursor to a drawn body row of the packet table. +// +// The window is model state, so the absolute row is simply the window's top plus the offset +// within it -- no guessing at a widget's private scroll position. +func (m Model) selectDrawnPacket(drawn int) Model { + if drawn < 0 { + return m + } + index := m.packetTop + drawn + if index >= len(m.visible) { + return m + } + m.cursor = index + m.follow = false + return m.revealCursor() +} diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/mouse_test.go b/plc4go/tools/plc4xpcapanalyzer/ui/mouse_test.go new file mode 100644 index 000000000..7c8618126 --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/ui/mouse_test.go @@ -0,0 +1,268 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package ui + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/tui" +) + +// The tview interface this replaced called EnableMouse(true), so clicking a pane and scrolling +// both worked. These tests exist because losing that was a silent capability regression and +// nothing else in the suite would have noticed. + +// leftClick sends a left click at a cell. +func leftClick(model Model, x, y int) Model { + return send(model, tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft}) +} + +func TestTheMouseIsEnabled(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, wide) + assert.Equal(t, tea.MouseModeCellMotion, model.View().MouseMode, + "the tview interface enabled the mouse, so this one must too") +} + +func TestClickingAPaneGivesItTheKeyboard(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, wide) + + for _, target := range []pane{paneCaptures, paneMain, paneDetail} { + t.Run(target.String(), func(t *testing.T) { + region, ok := model.regionForPane(target) + require.True(t, ok, "%s must be drawn at %s", target, wide) + + clicked := leftClick(model, (region.x0+region.x1)/2, (region.y0+region.y1)/2) + assert.Equal(t, tui.FocusPane, clicked.focus, "a click must move the keyboard into the panes") + assert.Equal(t, target, clicked.activePane(), "the clicked pane must be the active one") + }) + } +} + +func TestClickingOutsideThePanesReturnsToThePrompt(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, wide) + + region, ok := model.regionForPane(paneMain) + require.True(t, ok) + model = leftClick(model, region.x0+2, region.y0+2) + require.Equal(t, tui.FocusPane, model.focus) + + // The prompt sits on the second-to-last row. + model = leftClick(model, 4, wide.Height-2) + assert.Equal(t, tui.FocusPrompt, model.focus, "clicking the prompt row must return the keyboard to it") +} + +// TestClickingAPacketRowSelectsIt is the click-to-select the old list provided. +func TestClickingAPacketRowSelectsIt(t *testing.T) { + records := make([]Record, 0, 8) + for i := 1; i <= 8; i++ { + records = append(records, okRecord(i)) + } + model := analysed(t, records...) + + region, ok := model.regionForPane(paneMain) + require.True(t, ok) + + // One border row and one heading row precede the body. + first := leftClick(model, region.x0+4, region.y0+2) + second := leftClick(model, region.x0+4, region.y0+3) + + assert.Equal(t, first.cursor+1, second.cursor, + "clicking one row lower must select the next packet") + assert.False(t, second.follow, "picking a packet by hand must stop following the run") +} + +func TestClickingAPacketRowBeyondTheListIsIgnored(t *testing.T) { + model := analysed(t, okRecord(1)) + region, ok := model.regionForPane(paneMain) + require.True(t, ok) + + // Far below the single row that exists. + clicked := leftClick(model, region.x0+4, region.y1-1) + assert.Equal(t, model.cursor, clicked.cursor, "a click past the last packet must not move the cursor") +} + +// TestTheWheelScrollsThePaneUnderThePointerWithoutTakingFocus is what makes the wheel worth +// having: glancing at a pane must not cost the prompt. +func TestTheWheelScrollsThePaneUnderThePointerWithoutTakingFocus(t *testing.T) { + records := make([]Record, 0, 60) + for i := 1; i <= 60; i++ { + records = append(records, okRecord(i)) + } + model := analysed(t, records...) + require.Equal(t, tui.FocusPrompt, model.focus, "the prompt starts with the keyboard") + + region, ok := model.regionForPane(paneMain) + require.True(t, ok) + before := model.cursor + + scrolled := send(model, tea.MouseWheelMsg{X: region.x0 + 4, Y: region.y0 + 3, Button: tea.MouseWheelUp}) + assert.Less(t, scrolled.cursor, before, "the wheel must move the selection in the pane under the pointer") + assert.Equal(t, tui.FocusPrompt, scrolled.focus, "scrolling must not steal the keyboard") +} + +func TestTheWheelDoesNotChangeTheActivePane(t *testing.T) { + records := make([]Record, 0, 20) + for i := 1; i <= 20; i++ { + records = append(records, okRecord(i)) + } + model := analysed(t, records...) + before := model.activePane() + + region, ok := model.regionForPane(paneDetail) + require.True(t, ok) + scrolled := send(model, tea.MouseWheelMsg{X: region.x0 + 4, Y: region.y0 + 1, Button: tea.MouseWheelDown}) + + assert.Equal(t, before, scrolled.activePane(), + "the wheel scrolls under the pointer; it must not move focus there") +} + +// TestMouseRegionsDoNotOverlap guards the coordinate mapping: two panes claiming a cell would +// make clicks land unpredictably. +func TestMouseRegionsDoNotOverlap(t *testing.T) { + for _, size := range []tui.Size{wide, {Width: 100, Height: 24}, {Width: 80, Height: 24}, {Width: 60, Height: 12}} { + t.Run(size.String(), func(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, size) + regions := model.mouseRegions() + require.NotEmpty(t, regions) + + for i, a := range regions { + for j, b := range regions { + if i >= j { + continue + } + overlap := a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 + assert.False(t, overlap, "%s and %s overlap: %+v vs %+v", a.pane, b.pane, a, b) + } + } + }) + } +} + +// TestNoMouseRegionCoversThePromptRow is the mouse half of "the prompt is always reachable". +func TestNoMouseRegionCoversThePromptRow(t *testing.T) { + for _, size := range []tui.Size{wide, {Width: 100, Height: 24}, {Width: 80, Height: 24}, {Width: 60, Height: 12}} { + t.Run(size.String(), func(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, size) + promptRow := size.Height - 2 + for _, region := range model.mouseRegions() { + assert.False(t, region.contains(1, promptRow), + "%s covers the prompt row %d", region.pane, promptRow) + } + }) + } +} + +// TestOnlyTheLeftButtonActs keeps a right-click from doing something arbitrary in a tool with +// no context menu. +func TestOnlyTheLeftButtonActs(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, wide) + region, ok := model.regionForPane(paneMain) + require.True(t, ok) + + clicked := send(model, tea.MouseClickMsg{X: region.x0 + 4, Y: region.y0 + 2, Button: tea.MouseMiddle}) + assert.Equal(t, tui.FocusPrompt, clicked.focus, "a middle click must do nothing") +} + +// TestPaneTitlesShowTheirJumpKey is the discoverability half of the hotkey fix. +func TestPaneTitlesShowTheirJumpKey(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, wide) + joined := model.View().Content + + // The run panel is only drawn while a run is in flight, so only the three standing panes + // carry a label at rest. + for _, p := range []pane{paneCaptures, paneMain, paneDetail} { + assert.Contains(t, joined, paneLabel(p), "%s must advertise its jump key", p) + } +} + +// TestTheRunPanelShowsItsJumpKeyWhileRunning covers the fourth label, which only exists while +// there is a run to jump to. +func TestTheRunPanelShowsItsJumpKeyWhileRunning(t *testing.T) { + state, demo := demoState(t) + model := newTestModel(t, state, wide) + model = send(model, startAnalysisMsg{Request: state.RequestFor(demo.Protocol, demo.Path)}) + + assert.Contains(t, model.View().Content, paneLabel(paneRun), + "a running analysis must advertise the key that jumps to its panel") +} + +// TestABareDigitJumpsFromAnEmptyPrompt pins the portable half: alt+digit is swallowed by +// terminals that bind it to tab switching, so a bare digit has to work where it cannot be text. +func TestABareDigitJumpsFromAnEmptyPrompt(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, wide) + require.Equal(t, tui.FocusPrompt, model.focus) + + jumped := send(model, charKey('2')) + assert.Equal(t, tui.FocusPane, jumped.focus, "a digit at an empty prompt is a pane hotkey") + assert.Equal(t, paneMain, jumped.activePane()) +} + +func TestABareDigitIsTextOnceSomethingIsTyped(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, wide) + for _, r := range "analyze c-bu" { + model = send(model, charKey(r)) + } + model = send(model, charKey('2')) + + assert.Equal(t, tui.FocusPrompt, model.focus, "a digit inside a command is text") + assert.Equal(t, "analyze c-bu2", model.prompt.Value()) +} + +// TestCtrlDEndsTheSessionOnAnEmptyLine is the shell rule; ctrl+d could not simply join the +// quit binding because bubbles/textinput owns it for delete-forward. +func TestCtrlDEndsTheSessionOnAnEmptyLine(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, wide) + + asked := send(model, modKey('d', tea.ModCtrl)) + require.True(t, asked.confirmQuit, "ctrl+d on an empty line must offer to quit") + + _, cmd := sendWithCmd(asked, charKey('y')) + require.NotNil(t, cmd, "and quit once that is confirmed") + assert.Equal(t, tea.QuitMsg{}, cmd()) +} + +func TestCtrlDWithTextTypedDoesNotQuit(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, wide) + for _, r := range "help" { + model = send(model, charKey(r)) + } + require.NotEmpty(t, model.prompt.Value()) + + _, cmd := sendWithCmd(model, modKey('d', tea.ModCtrl)) + if cmd != nil { + assert.NotEqual(t, tea.QuitMsg{}, cmd(), + "with a line in progress ctrl+d belongs to the text input, not to quitting") + } +} diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/state.go b/plc4go/tools/plc4xpcapanalyzer/ui/state.go new file mode 100644 index 000000000..180e4762f --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/ui/state.go @@ -0,0 +1,267 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package ui + +import ( + "os" + "path/filepath" + "slices" + "strings" + + plc4go "github.com/apache/plc4x/plc4go/pkg/api" + "github.com/apache/plc4x/plc4go/pkg/api/drivers" + "github.com/apache/plc4x/plc4go/spi" + "github.com/apache/plc4x/plc4go/spi/errors" + "github.com/apache/plc4x/plc4go/spi/transports/pcap" + "github.com/rs/zerolog" + + cliConfig "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol" +) + +// The session state the commands read and write. +// +// It holds no view type and no Bubble Tea type on purpose. Commands are values-in, values-out +// functions over this struct, so the whole command layer can be tested without a terminal, +// and the model is the only thing that knows how any of it is drawn. +// +// State is owned by the model and is only ever touched on the event loop. The long-running +// analysis runs off the loop and therefore gets a Request value, not a pointer to this. + +// DriverNames are the plc4x drivers that can be registered in the subsystem. +// +// This is deliberately a superset of the protocols the analyzer can parse: registering the ADS +// or S7 driver is still useful for the plc4x internals that the trace settings expose, and the +// previous version offered exactly this list. +var DriverNames = []string{"ads", "bacnetip", "c-bus", "s7"} + +// Capture is a pcap file the session has open. +type Capture struct { + // Name is the file's base name, which is what the sidebar shows. + Name string + // Path is the absolute path. + Path string + // Packets is how many packets the last analysis of this capture walked, -1 when it has + // not been analysed yet. + Packets int + // Issues is how many findings the last analysis produced. + Issues int +} + +// AnalysisOptions are the analysis settings the sidebar's OPTIONS block shows, the conf +// command edits and a Request is seeded from. +// +// They are READ from the CLI configuration singletons rather than kept alongside them. Those +// singletons are what the cobra layer's analyze flags write to and what conf set edits, so +// keeping a second copy here would give the sidebar and the prompt two different answers to +// the same question. +type AnalysisOptions struct { + Filter string + NoFilter bool + OnlyParse bool + NoBytesCompare bool + StartPacket uint + PacketLimit uint +} + +// analysisOptions reads the analysis settings out of the CLI configuration singletons. +func analysisOptions() AnalysisOptions { + analyze := &cliConfig.AnalyzeConfigInstance + return AnalysisOptions{ + Filter: analyze.Filter, + NoFilter: analyze.NoFilter, + OnlyParse: analyze.OnlyParse, + NoBytesCompare: analyze.NoBytesCompare, + StartPacket: analyze.StartPackageNumber, + PacketLimit: analyze.PackageNumberLimit, + } +} + +// State is everything a command may read or change. +type State struct { + // CurrentDir is what ls, cd and the file completions are relative to. + CurrentDir string + // Captures are the files the session has open, and Current indexes the selected one, or is + // -1 when nothing is open. + Captures []Capture + Current int + // Protocol is the protocol analyses run as. + Protocol protocol.Protocol + // HostIP is the address treated as the client. Protocols where a request and a response + // are encoded differently cannot be parsed without it. + HostIP string + // Config is the configuration that survives between sessions. + Config Config + // Demo says the session is running against a generated capture. + Demo bool + // Debug turns on this tool's own trace logging, which the conf command toggles. + Debug bool + // LogLevel is the level the log pane is filtered at. + LogLevel zerolog.Level + + // DriverManager is the plc4x subsystem the register command registers drivers in. It is + // created lazily, because a session that only analyses captures never needs one. + DriverManager plc4go.PlcDriverManager + // Registered are the drivers registered so far, in registration order. + Registered []string +} + +// NewState builds the session state for a fresh run. +func NewState(currentDir string, config Config) *State { + state := &State{ + CurrentDir: currentDir, + Current: -1, + Protocol: protocol.CBus, + HostIP: config.HostIp, + Config: config, + LogLevel: zerolog.InfoLevel, + } + if level, err := zerolog.ParseLevel(config.LogLevel); err == nil && config.LogLevel != "" { + state.LogLevel = level + } + return state +} + +// CurrentCapture returns the selected capture, and false when nothing is open. +func (s *State) CurrentCapture() (Capture, bool) { + if s.Current < 0 || s.Current >= len(s.Captures) { + return Capture{}, false + } + return s.Captures[s.Current], true +} + +// Open adds a capture to the session and selects it. +// +// A relative path is resolved against the session's current directory, which is what makes +// "open cbus.pcap" work after a cd. +func (s *State) Open(pcapFile string) (Capture, error) { + if strings.TrimSpace(pcapFile) == "" { + return Capture{}, errors.New("open needs a file: open ") + } + if !filepath.IsAbs(pcapFile) { + pcapFile = filepath.Join(s.CurrentDir, pcapFile) + } + stat, err := os.Stat(pcapFile) + if err != nil { + return Capture{}, errors.Wrapf(err, "error opening %s", pcapFile) + } + if stat.IsDir() { + return Capture{}, errors.Errorf("%s is a directory", pcapFile) + } + for i, existing := range s.Captures { + if existing.Path == pcapFile { + // Already open is not a failure: select it, which is what the user wanted. + s.Current = i + return existing, nil + } + } + capture := Capture{Name: stat.Name(), Path: pcapFile, Packets: -1} + s.Captures = append(s.Captures, capture) + s.Current = len(s.Captures) - 1 + s.Config.RememberFile(pcapFile) + return capture, nil +} + +// Request builds the analysis request the current selection and options describe. +func (s *State) Request() (Request, error) { + capture, ok := s.CurrentCapture() + if !ok { + return Request{}, errors.New("no capture open: open first") + } + return s.RequestFor(s.Protocol, capture.Path), nil +} + +// RequestFor builds the analysis request for an explicit protocol and file. +// +// PacketLimit is deliberately NOT copied from the config singleton when that singleton still +// holds its zero value. The analyzer reads a limit of zero as "stop before the first packet", +// and the ui subcommand never parses the analyze flags that would otherwise set it to +// MaxUint — so the old UI's analyze command walked nothing at all and reported success. +func (s *State) RequestFor(proto protocol.Protocol, pcapFile string) Request { + options := analysisOptions() + return Request{ + Protocol: proto, + PcapFile: pcapFile, + Client: s.HostIP, + Filter: options.Filter, + NoFilter: options.NoFilter, + OnlyParse: options.OnlyParse, + NoBytesCompare: options.NoBytesCompare, + StartPacket: options.StartPacket, + PacketLimit: options.PacketLimit, + } +} + +// RecordAnalysis folds a finished run's totals back into the capture it analysed, so that the +// sidebar can show a packet count next to the file. +func (s *State) RecordAnalysis(result Result) { + for i, capture := range s.Captures { + if capture.Path == result.Request.PcapFile { + s.Captures[i].Packets = result.Counters.Walked + s.Captures[i].Issues = result.Counters.Issues() + return + } + } +} + +// IsRegistered reports whether a driver has been registered in the subsystem. +func (s *State) IsRegistered(driver string) bool { + return slices.Contains(s.Registered, driver) +} + +// RegisterDriver registers a plc4x driver, along with the pcap transport it needs to replay a +// capture through. +func (s *State) RegisterDriver(driver string) error { + if err := ValidateDriver(driver); err != nil { + return err + } + if s.IsRegistered(driver) { + return errors.Errorf("%s is already registered", driver) + } + if s.DriverManager == nil { + s.DriverManager = plc4go.NewPlcDriverManager() + } + switch driver { + case "ads": + drivers.RegisterAdsDriver(s.DriverManager) + case "bacnetip": + drivers.RegisterBacnetDriver(s.DriverManager) + case "c-bus": + drivers.RegisterCBusDriver(s.DriverManager) + case "s7": + drivers.RegisterS7Driver(s.DriverManager) + default: + return errors.Errorf("unknown driver %s", driver) + } + if transportAware, ok := s.DriverManager.(spi.TransportAware); ok { + transportAware.RegisterTransport(pcap.NewTransport()) + } + s.Registered = append(s.Registered, driver) + return nil +} + +// ValidateDriver reports whether a name is one of the registerable drivers. The error names +// them all, because that is what a user needs in order to recover from a typo. +func ValidateDriver(driver string) error { + if slices.Contains(DriverNames, driver) { + return nil + } + return errors.Errorf("unknown driver %q, known drivers are %s", driver, strings.Join(DriverNames, ", ")) +} diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/ui.go b/plc4go/tools/plc4xpcapanalyzer/ui/ui.go index e5f8843cb..ee26025fe 100644 --- a/plc4go/tools/plc4xpcapanalyzer/ui/ui.go +++ b/plc4go/tools/plc4xpcapanalyzer/ui/ui.go @@ -21,298 +21,171 @@ package ui import ( "context" - "fmt" - "math/rand" - "regexp" - "strconv" + "io" + "os" "time" - apiModel "github.com/apache/plc4x/plc4go/pkg/api/model" - "github.com/apache/plc4x/plc4go/spi" + tea "charm.land/bubbletea/v2" + "github.com/apache/plc4x/plc4go/spi/errors" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" - "github.com/gdamore/tcell/v2" - "github.com/pkg/errors" - "github.com/rivo/tview" + "github.com/apache/plc4x-extras/plc4go/tools/internal/tui" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol" ) -func SetupApplication() *tview.Application { - application := tview.NewApplication() +// Starting the program. +// +// This is the only place that touches the process: the temporary demo capture, the global +// logger, the terminal. Everything below it is a value the tests construct directly. + +// RunOptions configure a session. +type RunOptions struct { + // PcapFile is the capture named on the command line, if any. + PcapFile string + // Protocol is the protocol to analyse it as, empty for the default. + // + // Without it the interface starts on C-Bus, so opening a Modbus capture analysed it as + // C-Bus and filled the screen with parse failures that said nothing about either protocol. + // The protocol is switchable in the sidebar, but a capture named on the command line should + // not need a correction before it can be read. + Protocol string + // Demo generates a capture and analyses it, so the tool can be demonstrated and debugged + // with no capture and no device to hand. + Demo bool + // Ascii forces the ASCII glyph set, for a terminal that cannot draw the Unicode one. + Ascii bool + // Output is where the program draws. Leaving it nil means the real terminal. + Output io.Writer + // Clock is the time source, injected by tests. + Clock func() time.Time +} - newPrimitive := func(text string) tview.Primitive { - return tview.NewTextView(). - SetTextAlign(tview.AlignCenter). - SetText(text) +// Run starts the terminal UI and blocks until it exits. +// +// It returns errors rather than panicking. The version this replaces panicked from an init +// function when the user's configuration directory could not be created, and panicked again +// from the command layer whenever a config value of a non-string type was set. +func Run(ctx context.Context, options RunOptions) error { + if ctx == nil { + ctx = context.Background() } - fileArea := buildFileArea(newPrimitive, application) - outputArea := buildOutputArea(newPrimitive, application) - commandArea := buildCommandArea(newPrimitive, application) - grid := tview.NewGrid(). - SetRows(1, 0, 1). - SetColumns(30, 0, 30). - SetBorders(true). - AddItem(newPrimitive("PLC4X PCAP Analyzer"), 0, 0, 1, 3, 0, 0, false). - AddItem(newPrimitive("https://github.com/apache/plc4x"), 2, 0, 1, 3, 0, 0, false) + var demo Demo + if options.Demo { + generated, err := NewDemo() + if err != nil { + return err + } + demo = generated + // The capture is temporary, so it goes away with the session. A demo that leaves files + // behind in the user's temp directory is a demo that gets run once. + defer func() { _ = demo.Cleanup() }() + } - // Layout for screens narrower than 100 cells (fileArea and side bar are hidden). - grid.AddItem(fileArea, 0, 0, 0, 0, 0, 0, false). - AddItem(outputArea, 1, 0, 1, 3, 0, 0, false). - AddItem(commandArea, 0, 0, 0, 0, 0, 0, true) + currentDir, err := os.Getwd() + if err != nil { + currentDir = "." + } - // Layout for screens wider than 100 cells. - grid.AddItem(fileArea, 1, 0, 1, 1, 0, 100, false). - AddItem(outputArea, 1, 1, 1, 1, 0, 100, false). - AddItem(commandArea, 1, 2, 1, 1, 0, 100, false) + config, configErr := loadSessionConfig() + state := NewState(currentDir, config) + state.Demo = options.Demo - application.SetRoot(grid, true).EnableMouse(true) + if options.Protocol != "" { + resolved, err := protocol.Resolve(options.Protocol) + if err != nil { + return err + } + state.Protocol = resolved + } - return application -} + var startupErrors []string + if configErr != nil { + startupErrors = append(startupErrors, configErr.Error()) + } -func buildFileArea(newPrimitive func(text string) tview.Primitive, application *tview.Application) tview.Primitive { - connectionAreaHeader := newPrimitive("Files") - connectionArea := tview.NewGrid(). - SetRows(3, 0, 10). - SetColumns(0). - AddItem(connectionAreaHeader, 0, 0, 1, 1, 0, 0, false) - { - fileList := tview.NewList() - loadedPcapFilesChanged = func() { - application.QueueUpdateDraw(func() { - fileList.Clear() - for _, pcapFile := range loadedPcapFiles { - fileList.AddItem(pcapFile.name, pcapFile.path, 0x0, func() { - //TODO: disconnect popup - _ = pcapFile - }) - } - }) + if options.Demo { + state.Protocol = demo.Protocol + state.HostIP = demo.Client + if _, err := state.Open(demo.Path); err != nil { + return err } - connectionArea.AddItem(fileList, 1, 0, 1, 1, 0, 0, false) - { - registeredDriverAreaHeader := newPrimitive("Registered drivers") - registeredDriverArea := tview.NewGrid(). - SetRows(3, 0). - SetColumns(0). - AddItem(registeredDriverAreaHeader, 0, 0, 1, 1, 0, 0, false) - { - driverList := tview.NewList() - driverAdded = func(driver string) { - application.QueueUpdateDraw(func() { - driverList.AddItem(driver, "", 0x0, func() { - //TODO: disconnect popup - }) - }) - } - registeredDriverArea.AddItem(driverList, 1, 0, 1, 1, 0, 0, false) - } - connectionArea.AddItem(registeredDriverArea, 2, 0, 1, 1, 0, 0, false) + } + if options.PcapFile != "" { + if _, err := state.Open(options.PcapFile); err != nil { + // Not fatal. The session is still usable, and the message says exactly what failed. + startupErrors = append(startupErrors, err.Error()) } - } - return connectionArea -} - -func buildCommandArea(newPrimitive func(text string) tview.Primitive, application *tview.Application) tview.Primitive { - commandAreaHeader := newPrimitive("Commands") - commandArea := tview.NewGrid(). - SetRows(3, 0, 3). - SetColumns(0). - AddItem(commandAreaHeader, 0, 0, 1, 1, 0, 0, false) - { - enteredCommandsView := tview.NewTextView(). - SetDynamicColors(true). - SetRegions(true). - SetWordWrap(true). - SetChangedFunc(func() { - application.Draw() - }) - commandOutput = enteredCommandsView - commandOutputClear = func() { - enteredCommandsView.SetText("") + for _, driver := range state.Config.AutoRegisterDrivers { + if err := state.RegisterDriver(driver); err != nil { + startupErrors = append(startupErrors, err.Error()) } + } - commandArea.AddItem(enteredCommandsView, 1, 0, 1, 1, 0, 0, false) - - commandInputField := tview.NewInputField(). - SetLabel("$"). - SetFieldWidth(30) - application.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - switch event.Key() { - case tcell.KeyCtrlC: - commandInputField.SetText("") - application.SetFocus(commandInputField) - return nil - case tcell.KeyCtrlD: - // TODO: maybe add a modal here - application.Stop() - return nil - } - return event - }) - commandInputField. - SetDoneFunc(func(key tcell.Key) { - commandText := commandInputField.GetText() - if commandText == "quit" { - // TODO: maybe add a modal here - application.Stop() - return - } - commandsExecuted++ - go func() { - commandHistoryShortcut, _ := regexp.Compile("^[0-9]$") - if commandHistoryShortcut.MatchString(commandText) { - atoi, _ := strconv.Atoi(commandHistoryShortcut.FindString(commandText)) - if atoi < len(config.History.Last10Commands) { - commandText = config.History.Last10Commands[atoi] - } else { - _, _ = fmt.Fprintf(enteredCommandsView, "[#ff0000]%s %s[white]\n", time.Now().Format("04:05"), errors.Errorf("No such elements %d in command history", atoi)) - return - } - } - _, _ = fmt.Fprintf(enteredCommandsView, "%s [\"%d\"]%s[\"\"]\n", time.Now().Format("04:05"), commandsExecuted, commandText) - ctx, cancelFunc := context.WithCancel(rootContext) - randomId := rand.Uint32() - cancelFunctions[randomId] = cancelFunc - defer delete(cancelFunctions, randomId) + themeOptions := tui.OptionsFromEnv(true) + if options.Ascii { + themeOptions.ASCII = true + } - if err := Execute(ctx, commandText); err != nil { - _, _ = fmt.Fprintf(enteredCommandsView, "[#ff0000]%s %s[white]\n", time.Now().Format("04:05"), err) - return - } - application.QueueUpdateDraw(func() { - commandInputField.SetText("") - }) - }() - }) - commandInputField.SetAutocompleteFunc(rootCommand.Completions) + modelOptions := Options{Theme: themeOptions, State: state, Clock: options.Clock} + if options.Demo { + // Demo mode analyses its capture straight away, so that what the user sees when the + // program starts is the tool doing its job rather than an empty frame. + request := state.RequestFor(demo.Protocol, demo.Path) + modelOptions.AutoRun = &request + } + model := NewModel(modelOptions) + for _, message := range startupErrors { + model.appendLog("startup: " + message) + } - enteredCommandsView.SetDoneFunc(func(key tcell.Key) { - currentSelection := enteredCommandsView.GetHighlights() - if key == tcell.KeyEnter { - if len(currentSelection) > 0 { - enteredCommandsView.Highlight() - } else { - enteredCommandsView.Highlight("0").ScrollToHighlight() - } - if len(currentSelection) == 1 { - commandInputField.SetText(enteredCommandsView.GetRegionText(currentSelection[0])) - application.SetFocus(commandInputField) - } - } else if len(currentSelection) > 0 { - index, _ := strconv.Atoi(currentSelection[0]) - if key == tcell.KeyTab { - index = (index + 1) % commandsExecuted - } else if key == tcell.KeyBacktab { - index = (index - 1 + commandsExecuted) % commandsExecuted - } else { - return - } - enteredCommandsView.Highlight(strconv.Itoa(index)).ScrollToHighlight() - } - }) + // zerolog writes from whichever goroutine is logging, so it goes to a writer that does + // nothing but split lines and put them on a channel. The model drains that channel with a + // command; nothing reaches its state from outside the event loop. + restoreLogger := redirectLogger(model.LogWriter(), state.LogLevel) + defer restoreLogger() - commandArea.AddItem(commandInputField, 2, 0, 1, 1, 0, 0, true) + programOptions := []tea.ProgramOption{tea.WithContext(ctx)} + if options.Output != nil { + programOptions = append(programOptions, tea.WithOutput(options.Output)) + } + if _, err := tea.NewProgram(model, programOptions...).Run(); err != nil { + return errors.Wrap(err, "error running the terminal ui") } - return commandArea -} - -func buildOutputArea(newPrimitive func(text string) tview.Primitive, application *tview.Application) *tview.Grid { - outputAreaHeader := newPrimitive("Output") - outputArea := tview.NewGrid(). - SetRows(3, 0, 10). - SetColumns(0, 30). - AddItem(outputAreaHeader, 0, 0, 1, 1, 0, 0, false) - { - var jumpToMessageItem func(messageNumber int) bool - { - outputView := tview.NewTextView(). - SetDynamicColors(true). - SetRegions(true). - SetWordWrap(false). - SetWrap(false). - SetChangedFunc(func() { - application.Draw() - }) - jumpToMessageItem = func(messageNumber int) bool { - regionId := strconv.Itoa(messageNumber) - if outputView.GetRegionText(regionId) == "" { - return false - } - outputView.Highlight(regionId).ScrollToHighlight() - return true - } - messageOutput = outputView - messageOutputClear = func() { - outputView.SetText("") - } - outputView.SetDoneFunc(func(key tcell.Key) { - currentSelection := outputView.GetHighlights() - if key == tcell.KeyEnter { - if len(currentSelection) > 0 { - outputView.Highlight() - } else { - outputView.Highlight("0").ScrollToHighlight() - } - } else if len(currentSelection) > 0 { - index, _ := strconv.Atoi(currentSelection[0]) - if key == tcell.KeyTab { - index = (index + 1) % numberOfMessagesReceived - } else if key == tcell.KeyBacktab { - index = (index - 1 + numberOfMessagesReceived) % numberOfMessagesReceived - } else { - return - } - outputView.Highlight(strconv.Itoa(index)).ScrollToHighlight() - } - }) - outputView.SetBorder(false) - outputArea.AddItem(outputView, 1, 0, 1, 1, 0, 0, false) + if path, err := ConfigPath(); err == nil { + if err := SaveConfigTo(path, state.Config, time.Now()); err != nil { + return err } + } + return nil +} - { - consoleView := tview.NewTextView(). - SetDynamicColors(true). - SetMaxLines(config.MaxConsoleLines). - SetChangedFunc(func() { - application.Draw() - }) - consoleOutput = consoleView - consoleOutputClear = func() { - consoleView.SetText("") - } - - consoleView.SetBorder(false) - outputArea.AddItem(consoleView, 2, 0, 1, 1, 0, 0, false) - } +// LogWriter is an io.Writer whose lines end up in the log pane. +func (m Model) LogWriter() io.Writer { + return newLineWriter(channelSink(m.logCh)) +} - { - receivedMessagesList := tview.NewList() - messageReceived = func(messageNumber int, receiveTime time.Time, message apiModel.PlcMessage) { - application.QueueUpdateDraw(func() { - receivedMessagesList.AddItem(fmt.Sprintf("No %d @%s (api)", messageNumber, receiveTime.Format("15:04:05.999999")), "", 0x0, func() { - if ok := jumpToMessageItem(messageNumber); !ok { - plc4xpcapanalyzerLog.Debug().Msg("Adding new message to console output") - _, _ = fmt.Fprintf(messageOutput, "Message nr: %d\n[\"%d\"]%s[\"\"]\n", messageNumber, messageNumber, message) - jumpToMessageItem(messageNumber) - } - }) - }) - } - spiMessageReceived = func(messageNumber int, receiveTime time.Time, message spi.Message) { - application.QueueUpdateDraw(func() { - receivedMessagesList.AddItem(fmt.Sprintf("No %d @%s (spi)", messageNumber, receiveTime.Format("15:04:05.999999")), "", 0x0, func() { - if ok := jumpToMessageItem(messageNumber); !ok { - plc4xpcapanalyzerLog.Debug().Msg("Adding new spi message to console output") - _, _ = fmt.Fprintf(messageOutput, "Message nr: %d\n[\"%d\"]%s[\"\"]\n", messageNumber, messageNumber, message) - jumpToMessageItem(messageNumber) - } - }) - }) - } - outputArea.AddItem(receivedMessagesList, 0, 1, 3, 1, 0, 0, false) - } +// loadSessionConfig reads the session configuration, tolerating every way that can fail: a +// machine with no configuration directory, and a file that will not parse. +func loadSessionConfig() (Config, error) { + path, err := ConfigPath() + if err != nil { + return NewConfig(), err } - return outputArea + return LoadConfigFrom(path) +} + +// redirectLogger points the global logger at the UI and returns a function that puts it back. +// +// Console writing with colour switched off: the log pane applies the UI's own styling, and +// escape sequences arriving from underneath would fight it and corrupt the pane's widths. +func redirectLogger(writer io.Writer, level zerolog.Level) func() { + previous := log.Logger + log.Logger = zerolog.New(zerolog.ConsoleWriter{Out: writer, NoColor: true, TimeFormat: "15:04:05"}). + With().Timestamp().Logger(). + Level(level) + return func() { log.Logger = previous } } diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/ui_test.go b/plc4go/tools/plc4xpcapanalyzer/ui/ui_test.go new file mode 100644 index 000000000..7dd60b150 --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/ui/ui_test.go @@ -0,0 +1,494 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package ui + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/exp/teatest/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + cliConfig "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/config" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol" +) + +// Demo mode, the session configuration, and one end-to-end run of the real program. + +func TestDemoWritesACaptureOfRealTraffic(t *testing.T) { + demo, err := NewDemoIn(t.TempDir()) + require.NoError(t, err) + + assert.Equal(t, DemoCaptureName, filepath.Base(demo.Path)) + assert.Equal(t, protocol.CBus.Name, demo.Protocol.Name) + assert.NotEmpty(t, demo.Client, "C-Bus cannot tell a request from a response without a client address") + + stat, err := os.Stat(demo.Path) + require.NoError(t, err) + assert.Positive(t, stat.Size()) +} + +// TestDemoIsByteIdenticalEveryTime is what makes the demo usable as a fixture as well as a +// demonstration: the capture is built from fixed payloads and a fixed timestamp, never +// time.Now. +func TestDemoIsByteIdenticalEveryTime(t *testing.T) { + first, err := NewDemoIn(t.TempDir()) + require.NoError(t, err) + second, err := NewDemoIn(t.TempDir()) + require.NoError(t, err) + + firstBytes, err := os.ReadFile(first.Path) + require.NoError(t, err) + secondBytes, err := os.ReadFile(second.Path) + require.NoError(t, err) + assert.Equal(t, firstBytes, secondBytes) +} + +// TestDemoProducesFindings is the point of using the failure fixture. A demo where everything +// succeeds shows none of what the tool is for. +func TestDemoProducesFindings(t *testing.T) { + pinGlobals(t) + demo, err := NewDemoIn(t.TempDir()) + require.NoError(t, err) + + state := NewState(demo.Dir, NewConfig()) + state.HostIP = demo.Client + _, err = state.Open(demo.Path) + require.NoError(t, err) + + result := Analyze(t.Context(), state.RequestFor(demo.Protocol, demo.Path), Sink{}) + require.NoError(t, result.Err) + + // The exact mix, not merely "some of each". The quickstart tells the reader what to expect + // on screen, and a loose assertion here let the documentation drift from it: it claimed two + // failures when one of the two odd payloads is skipped rather than failed to parse, so the + // findings tab lists one row and not two. + counters := result.Counters + assert.Equal(t, 10, counters.Walked, "the demo capture is ten packets") + assert.Equal(t, 8, counters.Parsed, "eight of them round-trip") + assert.Equal(t, 1, counters.ParseFail, "one fails to parse") + assert.Equal(t, 1, counters.Skipped, "and one is skipped") + assert.Equal(t, 0, counters.SerializeFail) + assert.Equal(t, 0, counters.CompareFail) + assert.Equal(t, 1, counters.Issues(), "so the findings tab has exactly one row") + + // And the verdicts on the records agree with the counters. + verdicts := map[Verdict]int{} + for _, record := range result.Records { + verdicts[record.Verdict]++ + } + assert.Equal(t, 8, verdicts[VerdictOK]) + assert.Equal(t, 1, verdicts[VerdictParseFail]) + assert.Equal(t, 1, verdicts[VerdictSkipped]) +} + +func TestDemoCleanupRemovesTheCapture(t *testing.T) { + demo, err := NewDemo() + require.NoError(t, err) + require.FileExists(t, demo.Path) + + require.NoError(t, demo.Cleanup()) + assert.NoFileExists(t, demo.Path) + assert.NoDirExists(t, demo.Dir) + + assert.NoError(t, demo.Cleanup(), "cleanup has to be safe to run twice, because it runs from a defer") + assert.NoError(t, Demo{}.Cleanup(), "and safe on a demo that was never created") +} + +func TestDemoModeAnalysesItsCaptureAtStartup(t *testing.T) { + pinGlobals(t) + demo, err := NewDemoIn(t.TempDir()) + require.NoError(t, err) + state := NewState(demo.Dir, NewConfig()) + state.Demo = true + state.Protocol = demo.Protocol + state.HostIP = demo.Client + _, err = state.Open(demo.Path) + require.NoError(t, err) + + request := state.RequestFor(demo.Protocol, demo.Path) + model := NewModel(Options{Theme: testTheme(), State: state, Clock: fixedClock(), AutoRun: &request}) + model = send(model, tea.WindowSizeMsg{Width: 100, Height: 30}) + + // Init queues the run; running it is Update's job, so that one code path starts every run. + require.NotNil(t, model.Init()) + model = send(model, startAnalysisMsg{Request: request}) + require.True(t, model.run.active) + + result := Analyze(t.Context(), request, Sink{}) + model = send(model, analysisDoneMsg{Result: result}) + + screen := model.Render() + assert.Contains(t, screen, "DEMO") + assert.Contains(t, screen, DemoCaptureName) + assert.Contains(t, screen, "c-bus") + assert.NotEmpty(t, model.packets) + assert.Positive(t, len(model.findings()), "the demo's findings have to reach the table") +} + +// TestEndToEndInDemoMode runs the real program. +// +// It is the only test here that starts a Bubble Tea program, and it exists to prove the parts +// are wired together: Init arms the drains, the queued demo run reaches Update, the analysis +// runs on its own goroutine, its progress and its records come back as messages, and the quit +// binding ends the program. +func TestEndToEndInDemoMode(t *testing.T) { + pinGlobals(t) + demo, err := NewDemoIn(t.TempDir()) + require.NoError(t, err) + + state := NewState(demo.Dir, NewConfig()) + state.Demo = true + state.Protocol = demo.Protocol + state.HostIP = demo.Client + _, err = state.Open(demo.Path) + require.NoError(t, err) + + request := state.RequestFor(demo.Protocol, demo.Path) + model := NewModel(Options{Theme: testTheme(), State: state, Clock: fixedClock(), AutoRun: &request}) + + program := teatest.NewTestModel(t, model, teatest.WithInitialTermSize(100, 30)) + // Stop the program before pinGlobals puts the configuration singletons back. Cleanups run + // last-registered-first, and pinGlobals registered its restore before this, so this one + // runs first. Without it a failing assertion anywhere below returns through t.Fatal while + // the program is still rendering, and the restore races the render's read of those + // singletons -- which reports as a data race in whatever assertion happened to fail. + t.Cleanup(func() { _ = program.Quit() }) + + // The frame that proves the run reached Update: the counters row of the run panel, and a + // packet the codec actually named. + teatest.WaitFor(t, program.Output(), func(out []byte) bool { + return bytes.Contains(out, []byte("cbus-demo.pcap")) && + bytes.Contains(out, []byte("parse-fail")) && + bytes.Contains(out, []byte("CBusMessage")) + }, teatest.WithDuration(10*time.Second), teatest.WithCheckInterval(20*time.Millisecond)) + + // Quitting is confirmed, so it takes the question and then the answer. A second ctrl+c is + // the answer, which is also the end-to-end proof that the confirmation is reachable + // through a real program and not only through Update. + // Quitting is confirmed, and ctrl+c means "stop the run" while one is in flight, so how + // many presses it takes depends on whether this machine has finished the run yet: + // + // nothing running press 1 asks, press 2 confirms + // run in flight press 1 aborts, press 2 asks, press 3 confirms + // + // Three presses end the session either way, and a press delivered after the program has + // already stopped is dropped. Waiting for the run to finish first and sending exactly two + // was the flaky version of this: the records stream in while the run is still going, so + // there is no frame that reliably means "and now nothing is running". + // + // That the question is reachable at all, and that only a yes answers it, is pinned by + // TestQuitBindingsQuit and TestAnyOtherKeyKeepsTheSession against the real Update path. + for range 3 { + program.Send(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}) + } + program.WaitFinished(t, teatest.WithFinalTimeout(10*time.Second)) + + final, ok := program.FinalModel(t, teatest.WithFinalTimeout(10*time.Second)).(Model) + require.True(t, ok) + assert.True(t, final.quitting) + assert.NotEmpty(t, final.packets, "the analysis has to have populated the table before the program exited") +} + +func TestConfigRoundTrips(t *testing.T) { + pinGlobals(t) + path := filepath.Join(t.TempDir(), "nested", ConfigFileName) + + config := NewConfig() + config.HostIp = "10.1.2.3" + config.LogLevel = "debug" + config.MaxConsoleLines = 42 + config.RememberFile("/captures/a.pcap") + config.RememberCommand("analyze c-bus a.pcap") + require.NoError(t, config.EnableAutoRegister("c-bus")) + + require.NoError(t, SaveConfigTo(path, config, time.Unix(0, 0))) + + loaded, err := LoadConfigFrom(path) + require.NoError(t, err) + assert.Equal(t, "10.1.2.3", loaded.HostIp) + assert.Equal(t, "debug", loaded.LogLevel) + assert.Equal(t, 42, loaded.MaxConsoleLines) + assert.Equal(t, []string{"/captures/a.pcap"}, loaded.History.Last10Files) + assert.Equal(t, []string{"analyze c-bus a.pcap"}, loaded.History.Last10Commands) + assert.Equal(t, []string{"c-bus"}, loaded.AutoRegisterDrivers) + assert.Same(t, CliConfigInstances().RootConfig, loaded.CliConfigs.RootConfig, + "a loaded config has to point back at the live singletons, or conf set edits a copy") +} + +// TestLoadingAMissingConfigIsAFirstRunRatherThanAFailure keeps the tool starting on a fresh +// machine. +func TestLoadingAMissingConfigIsAFirstRunRatherThanAFailure(t *testing.T) { + loaded, err := LoadConfigFrom(filepath.Join(t.TempDir(), "absent.yml")) + require.NoError(t, err) + assert.Equal(t, NewConfig().MaxConsoleLines, loaded.MaxConsoleLines) +} + +// TestACorruptConfigStillYieldsUsableDefaults is what stops a bad file bricking the tool. The +// version this replaces panicked out of an init function on a configuration problem. +func TestACorruptConfigStillYieldsUsableDefaults(t *testing.T) { + path := filepath.Join(t.TempDir(), ConfigFileName) + require.NoError(t, os.WriteFile(path, []byte("this: [is not: yaml"), 0o600)) + + loaded, err := LoadConfigFrom(path) + require.Error(t, err) + assert.Equal(t, NewConfig().MaxConsoleLines, loaded.MaxConsoleLines) +} + +func TestRememberKeepsTheMostRecentAndDropsDuplicates(t *testing.T) { + config := NewConfig() + for i := range HistoryLimit + 5 { + config.RememberCommand("command " + itoa(i)) + } + assert.Len(t, config.History.Last10Commands, HistoryLimit) + assert.Equal(t, "command 14", config.History.Last10Commands[HistoryLimit-1]) + + config.RememberCommand("command 10") + assert.Equal(t, "command 10", config.History.Last10Commands[HistoryLimit-1], + "re-running a command moves it to the front rather than adding a second entry") + assert.Len(t, config.History.Last10Commands, HistoryLimit) +} + +// TestClearAndHistoryAreNotRemembered keeps the two commands whose whole purpose is to inspect +// the session out of the list of commands to recall. +func TestClearAndHistoryAreNotRemembered(t *testing.T) { + config := NewConfig() + config.RememberCommand("clear") + config.RememberCommand("history") + assert.Empty(t, config.History.Last10Commands) +} + +func TestConfigPathLivesUnderTheToolsOwnDirectory(t *testing.T) { + path, err := ConfigPath() + if err != nil { + t.Skip("no user configuration directory on this machine") + } + assert.True(t, strings.HasSuffix(path, filepath.Join("plc4xpcapanalyzer", ConfigFileName)), "got %q", path) +} + +func TestLineWriterEmitsWholeLinesOnly(t *testing.T) { + var got []string + writer := newLineWriter(func(line string) { got = append(got, line) }) + + _, err := writer.Write([]byte("first\nsec")) + require.NoError(t, err) + assert.Equal(t, []string{"first"}, got, "a partial line has to be held back, not shown as a fragment") + + _, err = writer.Write([]byte("ond\nthird\n")) + require.NoError(t, err) + assert.Equal(t, []string{"first", "second", "third"}, got) + + _, err = writer.Write([]byte("tail")) + require.NoError(t, err) + writer.Flush() + assert.Equal(t, []string{"first", "second", "third", "tail"}, got) +} + +func TestChannelSinkNeverBlocks(t *testing.T) { + ch := make(chan string, 2) + sink := channelSink(ch) + for range 100 { + sink("line") + } + assert.Len(t, ch, 2, "a full channel drops rather than throttling whatever is logging") +} + +func TestTheModelsLogWriterReachesTheLogPane(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, wide) + + _, err := model.LogWriter().Write([]byte("hello from a goroutine\n")) + require.NoError(t, err) + + // The line is on the channel; the drain turns it into a message. + msg := waitForLog(model.logCh)() + model = send(model, msg) + assert.Contains(t, strings.Join(model.logLines, "\n"), "hello from a goroutine") +} + +// --- configuration precedence --- + +// TestAFlagBeatsThePersistedConfig is the reported defect. The session configuration was +// decoded straight through the pointers it shares with the cobra layer, so it overwrote +// whatever the command line had just put there: a value persisted months ago beat the flag +// typed a second ago, silently and with no way to override it. +func TestAFlagBeatsThePersistedConfig(t *testing.T) { + pinGlobals(t) + t.Cleanup(cliConfig.ResetDefaults) + + // The registrations have run and nothing is parsed yet: this is the moment Execute records. + cliConfig.RootConfigInstance.LogLevel = "error" + cliConfig.PcapConfigInstance.Filter = "tcp port 10001" + cliConfig.SnapshotDefaults() + + // Now a command line asks for something. + cliConfig.RootConfigInstance.LogLevel = "debug" + + path := writeConfigWithCliSettings(t, map[string]any{ + "rootconfig": map[string]any{"loglevel": "warn"}, + "pcapconfig": map[string]any{"filter": "tcp port 20002"}, + }) + _, err := LoadConfigFrom(path) + require.NoError(t, err) + + assert.Equal(t, "debug", cliConfig.RootConfigInstance.LogLevel, + "the flag has to win over the persisted value") + assert.Equal(t, "tcp port 20002", cliConfig.PcapConfigInstance.Filter, + "and the persisted value has to win over the default it replaces") +} + +// TestThePersistedConfigBeatsTheDefault is the other half of the order, and the reason the +// file is read at all: conf set has to survive a restart. +func TestThePersistedConfigBeatsTheDefault(t *testing.T) { + pinGlobals(t) + t.Cleanup(cliConfig.ResetDefaults) + + cliConfig.RootConfigInstance.LogLevel = "error" + cliConfig.SnapshotDefaults() + + path := writeConfigWithCliSettings(t, map[string]any{ + "rootconfig": map[string]any{"loglevel": "trace"}, + }) + _, err := LoadConfigFrom(path) + require.NoError(t, err) + + assert.Equal(t, "trace", cliConfig.RootConfigInstance.LogLevel) +} + +// TestLoadingLeavesTheLiveSingletonsReachable checks the pointers are put back: conf set +// reflects over them, so a detached set would send every change into a copy nothing reads. +func TestLoadingLeavesTheLiveSingletonsReachable(t *testing.T) { + pinGlobals(t) + t.Cleanup(cliConfig.ResetDefaults) + cliConfig.SnapshotDefaults() + + config, err := LoadConfigFrom(writeConfigWithCliSettings(t, nil)) + require.NoError(t, err) + + require.NotNil(t, config.CliConfigs.RootConfig) + assert.Same(t, &cliConfig.RootConfigInstance, config.CliConfigs.RootConfig, + "the loaded config has to point at the live singleton") + assert.Same(t, &cliConfig.PcapConfigInstance, config.CliConfigs.PcapConfig) +} + +// TestWithNoSnapshotThePersistedConfigOnlyFillsGaps is the fallback when nothing recorded the +// defaults. Without them a flag cannot be identified, so anything already set is treated as +// deliberate and the file fills in only what is still empty. +func TestWithNoSnapshotThePersistedConfigOnlyFillsGaps(t *testing.T) { + pinGlobals(t) + cliConfig.ResetDefaults() + t.Cleanup(cliConfig.ResetDefaults) + + cliConfig.RootConfigInstance.LogLevel = "debug" + cliConfig.RootConfigInstance.LogType = "" + + path := writeConfigWithCliSettings(t, map[string]any{ + "rootconfig": map[string]any{"loglevel": "warn", "logtype": "json"}, + }) + _, err := LoadConfigFrom(path) + require.NoError(t, err) + + assert.Equal(t, "debug", cliConfig.RootConfigInstance.LogLevel, "a set value is left alone") + assert.Equal(t, "json", cliConfig.RootConfigInstance.LogType, "an empty one is filled in") +} + +// TestACorruptConfigDoesNotReachTheSingletons matters because the decode happens into detached +// structs: a half-decoded file must not leave the live configuration half-applied. +func TestACorruptConfigDoesNotReachTheSingletons(t *testing.T) { + pinGlobals(t) + t.Cleanup(cliConfig.ResetDefaults) + cliConfig.RootConfigInstance.LogLevel = "error" + cliConfig.SnapshotDefaults() + + path := filepath.Join(t.TempDir(), ConfigFileName) + require.NoError(t, os.WriteFile(path, []byte("cli_configs: [not, a, mapping\n"), 0o600)) + + _, err := LoadConfigFrom(path) + require.Error(t, err, "a corrupt file has to be reported") + assert.Equal(t, "error", cliConfig.RootConfigInstance.LogLevel, + "and must not have changed the live configuration") +} + +// writeConfigWithCliSettings writes a session configuration file carrying the given CLI +// settings, keyed the way the on-disk format keys them. +func writeConfigWithCliSettings(t *testing.T, settings map[string]any) string { + t.Helper() + document := map[string]any{"log_level": "info"} + if settings != nil { + document["cli_configs"] = settings + } + encoded, err := yaml.Marshal(document) + require.NoError(t, err) + + path := filepath.Join(t.TempDir(), ConfigFileName) + require.NoError(t, os.WriteFile(path, encoded, 0o600)) + return path +} + +// TestTheProtocolFlagChoosesWhatTheCaptureIsReadAs is a footgun found while writing a demo +// script. The interface starts on C-Bus, so a Modbus capture named on the command line was +// analysed as C-Bus and filled the screen with parse failures that said nothing about either +// protocol. The protocol was switchable in the sidebar, but a capture named on the command line +// should not need correcting before it can be read. +func TestTheProtocolFlagChoosesWhatTheCaptureIsReadAs(t *testing.T) { + pinGlobals(t) + + for name, test := range map[string]struct { + protocol string + want string + }{ + "unset defaults to c-bus": {"", protocol.CBus.Name}, + "canonical name": {"modbus-tcp", "modbus-tcp"}, + "another protocol": {"s7", "s7"}, + "an alias": {"logix", "eip"}, + "bacnet the browser way": {"bacnet-ip", protocol.BacnetIP.Name}, + } { + t.Run(name, func(t *testing.T) { + state := NewState(t.TempDir(), NewConfig()) + if test.protocol != "" { + resolved, err := protocol.Resolve(test.protocol) + require.NoError(t, err) + state.Protocol = resolved + } + assert.Equal(t, test.want, state.Protocol.Name) + }) + } +} + +// TestAnUnknownProtocolIsRefusedBeforeTheInterfaceStarts matters because the alternative is a +// terminal interface opening on a capture it cannot read, which looks like a broken tool. +func TestAnUnknownProtocolIsRefusedBeforeTheInterfaceStarts(t *testing.T) { + pinGlobals(t) + demo, err := NewDemoIn(t.TempDir()) + require.NoError(t, err) + + err = Run(t.Context(), RunOptions{PcapFile: demo.Path, Protocol: "nonsense"}) + require.Error(t, err, "an unknown protocol has to stop the run rather than start on a default") + assert.Contains(t, err.Error(), "nonsense") + // And it says what would have worked. + assert.Contains(t, err.Error(), "modbus-tcp") +} diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/view.go b/plc4go/tools/plc4xpcapanalyzer/ui/view.go new file mode 100644 index 000000000..87d3e6991 --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/ui/view.go @@ -0,0 +1,1388 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package ui + +import ( + "strconv" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/tui" + "github.com/apache/plc4x-extras/plc4go/tools/plc4xpcapanalyzer/internal/protocol" +) + +// Rendering. +// +// Every function here returns lines of an exact width, and the composition is done by zipping +// blocks rather than by letting a layout engine decide. That is deliberate: the defect this +// port exists to fix was a layout engine silently discarding a region — the command area, at +// anything under a hundred columns — and the only way to be sure that cannot happen again is +// for the row budget to be arithmetic that a test can check. + +// Row heights. The run panel takes four rows where there is room for them: two of chrome and +// two of content. Where there is not, it keeps the progress row and drops the counters, and +// below that it disappears entirely — but never at the expense of the prompt. +const ( + runPanelFull = 4 + runPanelCompact = 3 + // runPanelFullMin and runPanelCompactMin are the body heights those two need. + runPanelFullMin = 10 + runPanelCompactMin = 7 +) + +// hexBytesPerRow is the row width every hex dump in the detail pane uses. Eight rather than +// hex.Dump's sixteen because two dumps have to sit one above the other in a pane that is +// routinely under sixty cells wide. It is a package constant because the scroll extent has to +// count the same rows the renderer draws. +const hexBytesPerRow = 8 + +// View renders the program. +func (m Model) View() tea.View { + view := tea.NewView(m.Render()) + view.AltScreen = true + // The tview interface this replaces called EnableMouse(true); leaving the mouse off would + // have been a silent capability regression. CellMotion covers clicks and the wheel and is + // more widely supported than AllMotion, which would add motion events for no benefit here. + view.MouseMode = tea.MouseModeCellMotion + return view +} + +// Render produces the screen as a string. +// +// It is exported for tests: asserting on the content and the width of each line is far more +// useful than asserting on a golden blob, and it needs no terminal. +func (m Model) Render() string { + if m.size.Width <= 0 || m.size.Height <= 0 { + return "" + } + if m.layout.Mode == tui.ModeTooSmall { + return strings.Join(m.tooSmallLines(), "\n") + } + + width := m.size.Width + + geometry := m.geometry() + + lines := make([]string, 0, m.size.Height) + lines = append(lines, m.statusLine(width)) + lines = append(lines, m.bodyLines(width, geometry.PaneHeight)...) + if geometry.RunHeight > 0 { + lines = append(lines, m.runPanelLines(width, geometry.RunHeight)...) + } + lines = append(lines, m.toastLine(width)) + // The prompt, always, at its full width. This row is reserved before anything else is + // given a share of the screen. + lines = append(lines, fitLine(m.prompt.View(), width)) + lines = append(lines, m.footerLine(width)) + + return strings.Join(lines, "\n") +} + +// footerLine renders the one-line key footer. +// +// It always renders the SHORT help, even while the expanded help is showing: the expanded help +// is drawn over the body by applyOverlays, and letting help.Model render its multi-line form +// here too would grow the footer from one row to five and push the prompt off the screen. +func (m Model) footerLine(width int) string { + // While the quit question is up the footer says what answers it and nothing else, because + // every other binding is inert until the question is resolved. + if m.confirmQuit { + theme := m.theme + return fitLine(" "+theme.Accent.Render("y")+" "+theme.Muted.Render("quit")+ + theme.Chrome.Render(" "+theme.Glyphs.Separator+" ")+ + theme.Accent.Render("esc")+" "+theme.Muted.Render("stay"), width) + } + footer := m.footer + footer.SetShowAll(false) + return fitLine(footer.ViewWith(m.keys, m.tools.shortHelp(), m.tools.fullHelp()), width) +} + +// tooSmallLines is what a terminal below the minimum gets. It states the requirement and the +// actual size, rather than merely refusing, and it still draws the prompt when there is a row +// for it: the one rule that holds at every size is that the prompt is never dropped. +func (m Model) tooSmallLines() []string { + width, height := m.size.Width, m.size.Height + lines := []string{ + fitLine(m.theme.Warn.Render(tui.TooSmallMessage()), width), + fitLine(m.theme.Muted.Render(itoa(tui.MinWidth)+"x"+itoa(tui.MinHeight)+" minimum, this is "+m.size.String()), width), + } + for len(lines) < height-1 { + lines = append(lines, strings.Repeat(" ", width)) + } + if height >= 3 { + lines = append(lines, fitLine(m.prompt.View(), width)) + } + return lines[:min(len(lines), max(height, 1))] +} + +// geometry is the resolved row and column budget for one screen. +// +// It is computed in one place and consulted by both the renderer and the focus rules, because +// the two disagreeing is precisely how a pane ends up focusable but not drawn. +type geometry struct { + // PaneHeight is the rows the panes get, RunHeight the rows the run panel gets. + PaneHeight int + RunHeight int + // DetailHeight is the rows the detail pane gets inside PaneHeight, zero when it does not + // fit; SidebarWidth is the left column, zero when it does not fit. + DetailHeight int + SidebarWidth int + RightWidth int +} + +// geometry resolves the budget for the current size, layout and run state. +func (m Model) geometry() geometry { + resolved := geometry{RightWidth: m.size.Width} + bodyTotal := m.size.Height - tui.ReservedRows + if m.layout.Mode == tui.ModeTooSmall || bodyTotal < 1 { + return geometry{} + } + + if m.run.label != "" { + switch { + case bodyTotal >= runPanelFullMin: + resolved.RunHeight = runPanelFull + case bodyTotal >= runPanelCompactMin: + resolved.RunHeight = runPanelCompact + } + } + resolved.PaneHeight = bodyTotal - resolved.RunHeight + + if m.layout.Mode != tui.ModeWide { + return resolved + } + if sidebar := min(m.layout.SidebarWidth, m.size.Width-20); sidebar >= 16 { + resolved.SidebarWidth = sidebar + } + resolved.RightWidth = m.size.Width - resolved.SidebarWidth + // The detail pane needs enough rows to be worth the two it spends on its own border. Below + // that it is reached as an overlay instead. + if m.layout.ShowDetail && resolved.PaneHeight >= 12 { + resolved.DetailHeight = clamp(resolved.PaneHeight*40/100, 6, 14) + } + return resolved +} + +// bodyLines renders the pane area, exactly height lines of exactly width cells. +func (m Model) bodyLines(width, height int) []string { + if height < 1 { + return nil + } + var block string + switch { + case m.overlay != paneCount: + block = m.overlayPane(width, height) + case m.layout.Mode == tui.ModeWide: + block = m.wideBody(width, height) + default: + block = m.compactBody(width, height) + } + lines := fitBlock(block, width, height) + return m.applyOverlays(lines, width) +} + +// wideBody is the drawn layout: the sidebar on the left, and the main pane above the detail +// pane on the right. +func (m Model) wideBody(width, height int) string { + geometry := m.geometry() + sidebarWidth, rightWidth := geometry.SidebarWidth, geometry.RightWidth + detailHeight := geometry.DetailHeight + mainHeight := height - detailHeight + + right := m.mainPane(rightWidth, mainHeight) + if detailHeight > 0 { + right += "\n" + m.detailPane(rightWidth, detailHeight) + } + if sidebarWidth == 0 { + return right + } + return zipColumns(m.sidebarPane(sidebarWidth, height), sidebarWidth, right, rightWidth, height) +} + +// compactBody is the single-column layout. The sidebar and the detail pane become overlays +// reached by a key rather than columns competing for width that is not there. +func (m Model) compactBody(width, height int) string { + return m.mainPane(width, height) +} + +// overlayPane draws one pane over the whole body, which is how the sidebar and the detail pane +// are reached in compact mode. +func (m Model) overlayPane(width, height int) string { + switch m.overlay { + case paneCaptures: + return m.sidebarPane(width, height) + case paneDetail: + return m.detailPane(width, height) + default: + return m.mainPane(width, height) + } +} + +// applyOverlays draws the completion list or the expanded help over the bottom of the body. +// +// They are drawn over the body rather than given rows of their own because rows given to them +// would have to come from somewhere, and the only place left to take them from is the prompt. +func (m Model) applyOverlays(lines []string, width int) []string { + var block string + switch { + case m.footer.ShowAll(): + block = m.footer.ViewWith(m.keys, m.tools.shortHelp(), m.tools.fullHelp()) + case m.prompt.CompletionOpen(): + block = m.prompt.CompletionView(max(len(lines)-2, 1)) + } + if block == "" { + return lines + } + overlay := splitLines(block) + if len(overlay) > len(lines) { + overlay = overlay[len(overlay)-len(lines):] + } + start := len(lines) - len(overlay) + for i, line := range overlay { + lines[start+i] = fitLine(line, width) + } + return lines +} + +// statusLine renders the status bar, degrading as the width runs out. +// +// The order matters and is tested. The drawn header is about ninety cells wide and simply does +// not fit at eighty, so the parts are shed from the least useful: first the name of the +// focused pane, then the "client" label around the address, then the version, then the address +// itself, then the protocol. The application name and the demo badge are never shed — the +// badge because mistaking a demo capture for a real one is the one mistake this tool must not +// let a user make. +func (m Model) statusLine(width int) string { + // Drawn with the surfaced theme throughout, so the top bar and the command bar at the foot + // frame the panes between them the same way they do in the browser. Every space is inside + // a styled run rather than beside one: a terminal ends a background at the style's reset, + // so a bare space between two styled pieces would stripe the bar. + theme := m.theme.Surfaced() + space := theme.Surface.Render(" ") + + name := theme.Title.Render(AppName) + version := theme.Muted.Render(AppVersion) + badge := "" + if m.state.Demo { + badge = theme.Badge.Render("DEMO") + } + separator := theme.Chrome.Render(" " + theme.Glyphs.Separator + " ") + + protocolText := theme.Value.Render(m.state.Protocol.Name) + clientLong := "" + clientShort := "" + if m.state.HostIP != "" { + clientLong = theme.Muted.Render("client ") + theme.Value.Render(m.state.HostIP) + clientShort = theme.Value.Render(m.state.HostIP) + } + paneText := theme.Muted.Render("pane ") + theme.Value.Render(m.activePane().String()) + + assemble := func(head []string, tail []string) string { + left := strings.Join(nonEmpty(head), space) + right := strings.Join(nonEmpty(tail), separator) + if right == "" { + return padOnto(theme.Surface, left, width) + } + gap := width - lipgloss.Width(left) - lipgloss.Width(right) - 1 + if gap < 1 { + return "" + } + return left + theme.Surface.Render(strings.Repeat(" ", gap)) + right + space + } + + for _, candidate := range []string{ + assemble([]string{name, version, badge}, []string{protocolText, clientLong, paneText}), + assemble([]string{name, version, badge}, []string{protocolText, clientLong}), + assemble([]string{name, version, badge}, []string{protocolText, clientShort}), + assemble([]string{name, badge}, []string{protocolText, clientShort}), + assemble([]string{name, badge}, []string{protocolText}), + assemble([]string{name, badge}, nil), + } { + if candidate != "" && lipgloss.Width(candidate) <= width { + return fitLine(candidate, width) + } + } + return padOnto(theme.Surface, name+space+badge, width) +} + +// toastLine renders the quit question or the notice above the prompt. +func (m Model) toastLine(width int) string { + if m.confirmQuit { + return m.confirmQuitLine(width) + } + if m.toast == "" { + return strings.Repeat(" ", width) + } + glyph, style := m.theme.Glyphs.Ok, m.theme.Ok + if m.toastErr { + glyph, style = m.theme.Glyphs.Err, m.theme.Err + } + return fitLine(" "+style.Render(glyph+" "+m.toast)+m.theme.Muted.Render(" esc dismiss"), width) +} + +// confirmQuitLine asks the question, and says what will and will not answer it. +// +// It sits in the row the toast would use, directly above the prompt, so the question is next to +// the keyboard rather than somewhere the eye has to find. +func (m Model) confirmQuitLine(width int) string { + theme := m.theme + line := " " + theme.Warn.Render(theme.Glyphs.Warn+" quit?") + " " + + theme.Muted.Render("the open capture will be closed") + hint := theme.Accent.Render("y") + theme.Muted.Render(" or ") + + theme.Accent.Render("ctrl+c") + theme.Muted.Render(" quit") + + theme.Chrome.Render(" "+theme.Glyphs.Separator+" ") + + theme.Accent.Render("any other key") + theme.Muted.Render(" stay") + if gap := width - lipgloss.Width(line) - lipgloss.Width(hint) - 1; gap > 0 { + line += strings.Repeat(" ", gap) + hint + } + return fitLine(line, width) +} + +// sidebarPane draws the captures, drivers and options column. +func (m Model) sidebarPane(width, height int) string { + rows := m.sidebarRows() + selectable := m.sidebarSelectable() + inner := max(width-2, 1) + visibleRows := max(height-2, 1) + + // Keep the cursor on screen. The sidebar has no scrollbar; the ellipsis row at the bottom + // says when there is more below. + cursorRow := 0 + if m.sidebarCursor < len(selectable) { + cursorRow = selectable[m.sidebarCursor] + } + top := 0 + if cursorRow >= top+visibleRows { + top = cursorRow - visibleRows + 1 + } + top = max(min(top, max(len(rows)-visibleRows, 0)), 0) + + focused := m.activePane() == paneCaptures && m.focus != tui.FocusPrompt + var content strings.Builder + for i := top; i < min(top+visibleRows, len(rows)); i++ { + if i > top { + content.WriteString("\n") + } + content.WriteString(m.renderSidebarRow(rows[i], i == cursorRow && focused, inner)) + } + + pane := tui.Pane{ + Title: paneTitle(paneCaptures), + Status: itoa(len(m.state.Captures)), + Width: width, + Height: height, + Focused: focused, + } + return pane.Render(m.theme, content.String()) +} + +// sidebarKind says how a sidebar row is drawn and what selecting it does. +type sidebarKind int + +const ( + sidebarHeader sidebarKind = iota + sidebarCapture + sidebarDriver + sidebarOption + sidebarHint + sidebarBlank +) + +// sidebarRow is one line of the sidebar. +type sidebarRow struct { + kind sidebarKind + label string + value string + // key identifies what the row acts on: a capture path, a driver name, an option name. + key string +} + +// selectable reports whether the cursor can land on this row. Selecting a row always does +// something; a row that would do nothing is not selectable in the first place. +func (r sidebarRow) selectable() bool { + switch r.kind { + case sidebarCapture, sidebarDriver, sidebarOption: + return true + default: + return false + } +} + +// sidebarRows builds the sidebar's contents. +func (m Model) sidebarRows() []sidebarRow { + rows := make([]sidebarRow, 0, 24) + if len(m.state.Captures) == 0 { + // An empty pane says what to do next, and names the command that does it. + rows = append(rows, + sidebarRow{kind: sidebarHint, label: "no captures open"}, + sidebarRow{kind: sidebarHint, label: "open "}) + } + for _, capture := range m.state.Captures { + value := "-" + if capture.Packets >= 0 { + value = itoa(capture.Packets) + } + rows = append(rows, sidebarRow{kind: sidebarCapture, label: capture.Name, value: value, key: capture.Path}) + } + rows = append(rows, + sidebarRow{kind: sidebarBlank}, + sidebarRow{kind: sidebarHint, label: m.state.CurrentDir}, + sidebarRow{kind: sidebarBlank}, + sidebarRow{kind: sidebarHeader, label: "DRIVERS", value: itoa(len(m.state.Registered))}) + for _, driver := range DriverNames { + marker := m.theme.Glyphs.No + if m.state.IsRegistered(driver) { + marker = m.theme.Glyphs.Yes + } + rows = append(rows, sidebarRow{kind: sidebarDriver, label: driver, value: marker, key: driver}) + } + + options := analysisOptions() + filter := options.Filter + if filter == "" { + filter = "default" + } + if options.NoFilter { + filter = "disabled" + } + client := m.state.HostIP + if client == "" { + client = "unset" + } + rows = append(rows, + sidebarRow{kind: sidebarBlank}, + sidebarRow{kind: sidebarHeader, label: "OPTIONS"}, + sidebarRow{kind: sidebarOption, label: "protocol", value: m.state.Protocol.Name, key: "protocol"}, + sidebarRow{kind: sidebarOption, label: "client", value: client, key: "client"}, + sidebarRow{kind: sidebarOption, label: "filter", value: filter, key: "filter"}, + sidebarRow{kind: sidebarOption, label: "no-filter", value: onOff(options.NoFilter), key: "no-filter"}, + sidebarRow{kind: sidebarOption, label: "only-parse", value: onOff(options.OnlyParse), key: "only-parse"}, + sidebarRow{kind: sidebarOption, label: "bytes-compare", value: onOff(!options.NoBytesCompare), key: "bytes-compare"}) + return rows +} + +// sidebarSelectable indexes the rows the cursor may land on. +func (m Model) sidebarSelectable() []int { + var indexes []int + for i, row := range m.sidebarRows() { + if row.selectable() { + indexes = append(indexes, i) + } + } + return indexes +} + +// renderSidebarRow draws one row at an exact width. +func (m Model) renderSidebarRow(row sidebarRow, selected bool, width int) string { + switch row.kind { + case sidebarBlank: + return strings.Repeat(" ", width) + case sidebarHeader: + return fitLine(" "+m.theme.Key.Render(row.label)+" "+m.theme.Muted.Render(row.value), width) + case sidebarHint: + return fitLine(" "+m.theme.Muted.Render(shorten(row.label, width-2, m.theme.Glyphs.Ellipsis)), width) + } + + marker := m.theme.Glyphs.Unselected + style := m.theme.Value + if selected { + marker = m.theme.Glyphs.Selected + style = m.theme.SelectedRow + } + // The label wins the width contest. A label cut down to "cli." says nothing at all, + // whereas a truncated address is still recognisable, and the value has a full-width home + // in the status bar and in conf list anyway. + label := shorten(row.label, max(width-7, 1), m.theme.Glyphs.Ellipsis) + value := shorten(row.value, max(width-lipgloss.Width(label)-3, 1), m.theme.Glyphs.Ellipsis) + gap := max(width-2-lipgloss.Width(label)-lipgloss.Width(value)-1, 1) + return fitLine(marker+" "+style.Render(label)+strings.Repeat(" ", gap)+m.theme.Muted.Render(value), width) +} + +// activateSidebarRow performs the action of the highlighted sidebar row. +func (m Model) activateSidebarRow() (tea.Model, tea.Cmd) { + rows := m.sidebarRows() + selectable := m.sidebarSelectable() + if len(selectable) == 0 || m.sidebarCursor >= len(selectable) { + return m, nil + } + row := rows[selectable[m.sidebarCursor]] + switch row.kind { + case sidebarCapture: + for i, capture := range m.state.Captures { + if capture.Path == row.key { + m.state.Current = i + m.setToast("selected "+capture.Name, false) + } + } + return m, nil + + case sidebarDriver: + verb := "register " + if m.state.IsRegistered(row.key) { + verb = "unregister " + } + return m.runLine(verb + row.key) + + case sidebarOption: + return m.activateOption(row.key) + } + return m, nil +} + +// activateOption toggles or edits one analysis option. +// +// A boolean is flipped in place; a value that needs typing pre-fills the prompt with the +// command that sets it, so that the sidebar and the command line never disagree about where +// the setting lives. +func (m Model) activateOption(name string) (tea.Model, tea.Cmd) { + switch name { + case "protocol": + all := protocol.All() + next := all[0] + for i, candidate := range all { + if candidate.Name == m.state.Protocol.Name { + next = all[(i+1)%len(all)] + break + } + } + m.state.Protocol = next + m.setToast("protocol "+next.Name, false) + return m, nil + case "client": + return m.prefill("host set " + m.state.HostIP) + case "filter": + // PcapConfig, not AnalyzeConfig: Filter is declared on the config AnalyzeConfig embeds, + // and the conf tree addresses a field by the struct that declares it. + return m.prefill("conf set PcapConfig Filter " + analysisOptions().Filter) + case "no-filter": + return m.runLine("conf set AnalyzeConfig NoFilter " + strconv.FormatBool(!analysisOptions().NoFilter)) + case "only-parse": + return m.runLine("conf set AnalyzeConfig OnlyParse " + strconv.FormatBool(!analysisOptions().OnlyParse)) + case "bytes-compare": + return m.runLine("conf set AnalyzeConfig NoBytesCompare " + strconv.FormatBool(!analysisOptions().NoBytesCompare)) + } + return m, nil +} + +// mainPane draws the tabbed packets, log and findings pane. +func (m Model) mainPane(width, height int) string { + focused := m.activePane() == paneMain && m.focus != tui.FocusPrompt + inner := max(width-2, 1) + rowsAvailable := max(height-2, 1) + + var content string + switch m.tab { + case tabLog: + content = strings.Join(m.logRows(inner, rowsAvailable), "\n") + default: + content = strings.Join(m.packetRows(inner, rowsAvailable), "\n") + } + + pane := tui.Pane{ + Title: paneLabel(paneMain) + " " + m.tabStrip(), + Status: m.mainStatus(), + Width: width, + Height: height, + Focused: focused, + } + return pane.Render(m.theme, content) +} + +// tabStrip is the tab list carried in the main pane's border. The active tab is marked with +// the selection glyph rather than a colour, so that it stays legible with colour switched off +// and so that the pane's own title styling is not fought with a nested style. +func (m Model) tabStrip() string { + parts := make([]string, 0, tabCount) + for t := range tabCount { + if t == m.tab { + parts = append(parts, m.theme.Glyphs.Selected+t.String()) + continue + } + parts = append(parts, " "+t.String()) + } + return strings.TrimSpace(strings.Join(parts, " ")) +} + +// mainStatus is the count carried at the right of the main pane's border. +func (m Model) mainStatus() string { + if m.filtering || m.filter != "" { + return "/" + m.filter + " " + itoa(len(m.visible)) + " of " + itoa(len(m.packets)) + } + switch m.tab { + case tabLog: + return itoa(len(m.logLines)) + " lines" + case tabFindings: + return itoa(len(m.visible)) + " findings" + default: + issues := len(m.findings()) + if issues == 0 { + return itoa(len(m.packets)) + " " + m.theme.Glyphs.Separator + " no issues" + } + return itoa(len(m.packets)) + " " + m.theme.Glyphs.Separator + " " + itoa(issues) + " issues" + } +} + +// packetColumns are the fixed-width columns of the packets table, widest layout first. The +// message column takes whatever is left. +type packetColumn struct { + title string + width int +} + +// packetRows renders the packets table, header included. +func (m Model) packetRows(width, rows int) []string { + if len(m.packets) == 0 { + return m.emptyPackets(width, rows) + } + if len(m.visible) == 0 { + return padRows([]string{ + fitLine(" "+m.theme.Muted.Render("nothing matches "+strconv.Quote(m.filter)), width), + fitLine(" "+m.theme.Muted.Render("esc clears the filter"), width), + }, width, rows) + } + + columns := packetLayout(width) + header := m.renderPacketHeader(columns, width) + bodyRows := max(rows-1, 1) + + // The window is model state, not a function of the cursor: deriving it here pinned the + // selection to the bottom row. Clamp it to what is drawable in case the pane shrank since + // the cursor last moved. + top := min(max(m.packetTop, 0), max(len(m.visible)-bodyRows, 0)) + if m.cursor < top { + top = m.cursor + } else if m.cursor >= top+bodyRows { + top = m.cursor - bodyRows + 1 + } + out := []string{header} + for i := top; i < min(top+bodyRows, len(m.visible)); i++ { + out = append(out, m.renderPacketRow(m.packets[m.visible[i]], i == m.cursor, columns, width)) + } + return padRows(out, width, rows) +} + +// emptyPackets is the empty state. It names the command that would fill the pane, with the +// arguments the session already has, rather than merely saying that there is nothing here. +func (m Model) emptyPackets(width, rows int) []string { + hint := "analyze " + m.state.Protocol.Name + " " + if capture, ok := m.state.CurrentCapture(); ok { + hint = "analyze " + m.state.Protocol.Name + " " + capture.Name + } + lines := []string{ + fitLine(" "+m.theme.Muted.Render("no packets analysed yet"), width), + fitLine(" "+m.theme.Muted.Render("run: ")+m.theme.Accent.Render(hint), width), + } + if len(m.state.Captures) == 0 { + lines = append(lines, fitLine(" "+m.theme.Muted.Render("or open one first: ")+m.theme.Accent.Render("open "), width)) + } + return padRows(lines, width, rows) +} + +// packetLayout picks the columns that fit the available width, dropping the least useful +// first. The number, the message and the verdict always survive: they are what makes a row +// worth reading at all. +func packetLayout(width int) []packetColumn { + full := []packetColumn{ + {"no.", 5}, {"time", 10}, {"dir", 3}, {"proto", 7}, {"message", 0}, {"verdict", 7}, + } + drop := []string{"proto", "dir", "time"} + columns := full + for { + fixed := 2 // the selection marker and the space after it + for _, column := range columns { + fixed += column.width + 1 + } + if fixed+minMessageWidth <= width || len(drop) == 0 { + return columns + } + name := drop[0] + drop = drop[1:] + var kept []packetColumn + for _, column := range columns { + if column.title != name { + kept = append(kept, column) + } + } + columns = kept + } +} + +// minMessageWidth is the narrowest the message column may be before a whole column is dropped +// instead. +const minMessageWidth = 12 + +// renderPacketHeader draws the table's header row. +func (m Model) renderPacketHeader(columns []packetColumn, width int) string { + cells := make([]string, 0, len(columns)) + for _, column := range columns { + cells = append(cells, pad(column.title, m.columnWidth(column, columns, width))) + } + return fitLine(" "+m.theme.Key.Render(strings.Join(cells, " ")), width) +} + +// renderPacketRow draws one packet. +func (m Model) renderPacketRow(record Record, selected bool, columns []packetColumn, width int) string { + marker := m.theme.Glyphs.Unselected + if selected { + marker = m.theme.Glyphs.Selected + } + // One style per cell rather than one for the whole row. A terminal ends a style at the next + // reset, so a semantic colour anywhere in the row would drop the row's own styling for every + // cell after it -- the defect that made the browser's selected row invisible. Building the + // row from styled cells says what each column means and keeps the selection intact. + rowStyle := m.theme.Value + if selected { + rowStyle = m.theme.SelectedRow + } + + cells := make([]string, 0, len(columns)) + for _, column := range columns { + size := m.columnWidth(column, columns, width) + value, style := "", rowStyle + switch column.title { + case "no.": + value, style = itoa(record.Number), m.contextStyle(selected) + case "time": + value, style = formatOffset(record.Offset), m.contextStyle(selected) + case "dir": + value = m.directionGlyph(record.Direction) + if !selected { + style = m.directionStyle(record.Direction) + } + case "proto": + value, style = record.Protocol, m.contextStyle(selected) + case "message": + value = record.Summary + if value == "" { + value = record.Reason + } + case "verdict": + // The verdict keeps its own colour even on the selected row: it is the answer the + // tool exists to give, and five other cells plus the marker already say which row + // is selected. + value, style = record.Verdict.String(), m.verdictStyle(record.Verdict) + } + cells = append(cells, style.Render(pad(shorten(value, size, m.theme.Glyphs.Ellipsis), size))) + } + + row := rowStyle.Render(marker+" ") + strings.Join(cells, rowStyle.Render(" ")) + return fitLine(padOnto(rowStyle, row, width), width) +} + +// contextStyle is for a cell carrying context rather than substance -- the number, the offset, +// the protocol. On the selected row it stays with the selection, so the row reads as one thing. +func (m Model) contextStyle(selected bool) lipgloss.Style { + if selected { + return m.theme.SelectedRow + } + return m.theme.Muted +} + +// verdictStyle colours an outcome by what it means: a defect is red, a clean round trip green, +// and everything else -- skipped, filtered, no payload -- is muted, because those are not +// findings and should not read as though they were. +func (m Model) verdictStyle(verdict Verdict) lipgloss.Style { + switch { + case verdict.IsIssue(): + return m.theme.Err + case verdict == VerdictOK: + return m.theme.Ok + default: + return m.theme.Muted + } +} + +// directionStyle distinguishes the two directions by colour as well as by glyph, so a request +// and its reply can be told apart down the column rather than one row at a time. +func (m Model) directionStyle(direction Direction) lipgloss.Style { + if direction == DirectionResponse { + return m.theme.Temporal + } + return m.theme.Numeric +} + +// columnWidth resolves a column's width, giving the message column whatever is left. +func (m Model) columnWidth(column packetColumn, columns []packetColumn, width int) int { + if column.width > 0 { + return column.width + } + used := 2 + for _, other := range columns { + if other.width > 0 { + used += other.width + 1 + } + } + return max(width-used-1, 4) +} + +// directionGlyph renders which way a packet travelled. +func (m Model) directionGlyph(direction Direction) string { + switch direction { + case DirectionRequest: + return m.theme.Glyphs.Outbound + case DirectionResponse: + return m.theme.Glyphs.Inbound + default: + return "?" + } +} + +// logRows renders the log tab. +func (m Model) logRows(width, rows int) []string { + if len(m.logLines) == 0 { + return padRows([]string{ + fitLine(" "+m.theme.Muted.Render("nothing logged yet"), width), + fitLine(" "+m.theme.Muted.Render("commands and analysis output appear here"), width), + }, width, rows) + } + top := clampIndex(m.logTop, len(m.logLines)) + if len(m.logLines)-top < rows { + top = max(len(m.logLines)-rows, 0) + } + out := make([]string, 0, rows) + for i := top; i < min(top+rows, len(m.logLines)); i++ { + out = append(out, fitLine(" "+m.theme.Value.Render(shorten(m.logLines[i], width-1, m.theme.Glyphs.Ellipsis)), width)) + } + return padRows(out, width, rows) +} + +// detailInnerWidth is the width the detail pane's content is drawn at: the whole screen when +// it is showing as an overlay, and the right-hand column otherwise. The scroll extent needs it +// because wrapping decides how many lines the tree view has. +func (m Model) detailInnerWidth() int { + if m.overlay == paneDetail { + return max(m.size.Width-2, 1) + } + return max(m.geometry().RightWidth-2, 1) +} + +// detailPane draws the original bytes against what the codec wrote back. +func (m Model) detailPane(width, height int) string { + focused := m.activePane() == paneDetail && m.focus != tui.FocusPrompt + inner := max(width-2, 1) + rows := max(height-2, 1) + + title := paneTitle(paneDetail) + var content []string + if record, ok := m.selectedRecord(); ok { + title = paneTitle(paneDetail) + " " + m.theme.Glyphs.Separator + " packet " + itoa(record.Number) + content = m.detailLines(record, rows, inner) + } else { + content = []string{ + m.theme.Muted.Render("no packet selected"), + m.theme.Muted.Render("move the cursor in Packets, then press enter"), + } + } + + pane := tui.Pane{ + Title: title, + Status: "[b]ytes [t]ree [d]iff", + Width: width, + Height: height, + Focused: focused, + } + return pane.Render(m.theme, strings.Join(content, "\n")) +} + +// detailLines renders the detail pane's body for one record. +func (m Model) detailLines(record Record, rows, width int) []string { + switch m.detail { + case detailBytes: + return m.detailBytesLines(record, rows, width) + case detailTree: + return m.detailTreeLines(record, rows, width) + default: + return m.detailDiffLines(record, rows, width) + } +} + +// detailDiffLines is the view this tool exists for: the captured payload against the +// re-serialized one, with the first differing offset marked. +// +// The five load-bearing rows — the two previews, the two hex rows and the caret — are built +// first and a blank separator is inserted only when there is a row to spare. The pane is +// routinely six rows tall, and dropping the caret to make room for whitespace would throw away +// the one line that says what is actually wrong. +func (m Model) detailDiffLines(record Record, rows, width int) []string { + const ( + bytesPerRow = hexBytesPerRow + labelWidth = 14 + ) + label := func(text string) string { return m.theme.Key.Render(pad(text, labelWidth)) } + + // The verdict sits at the right of the reserialized row, so the space it needs comes off + // the preview column rather than off the end of the pane. + verdict := "" + if record.DiffOffset >= 0 { + verdict = m.theme.Glyphs.Err + " " + record.Reason + } + previewWidth := max(width-labelWidth-lipgloss.Width(verdict)-2, 8) + + original := fitLine(label("original")+m.theme.Value.Render(Preview(record.Original, previewWidth, m.theme.Glyphs.Ellipsis)), width) + + switch { + case record.Reserialized == nil: + lines := []string{ + original, + fitLine(label("reserialized")+m.theme.Muted.Render("(not produced: "+record.Verdict.String()+")"), width), + } + if record.Reason != "" { + lines = append(lines, fitLine(m.theme.Err.Render(m.theme.Glyphs.Err+" "+shorten(record.Reason, width-2, m.theme.Glyphs.Ellipsis)), width)) + } + lines = append(lines, m.hexBlock(record.Original, 0, rows-len(lines), width)...) + return padRows(lines, width, rows) + + case record.DiffOffset < 0: + lines := []string{ + original, + fitLine(label("reserialized")+m.theme.Ok.Render(m.theme.Glyphs.Ok+" identical, "+itoa(len(record.Original))+" bytes"), width), + } + lines = append(lines, m.hexBlock(record.Original, 0, rows-len(lines), width)...) + return padRows(lines, width, rows) + } + + // Both dumps start at the row containing the first difference, so the two lines the reader + // has to compare are directly above one another and the caret lines up with both. + rowStart := record.DiffOffset / bytesPerRow * bytesPerRow + essential := []string{ + original, + // The preview is padded to its full column so the verdict lands in the same place on + // every packet, rather than jumping about with the length of the payload. + fitLine(label("reserialized")+m.theme.Value.Render(pad(Preview(record.Reserialized, previewWidth, m.theme.Glyphs.Ellipsis), previewWidth))+ + " "+m.theme.Err.Render(verdict), width), + fitLine(m.theme.Value.Render(hexRowFrom(record.Original, rowStart, bytesPerRow)), width), + fitLine(m.theme.Value.Render(hexRowFrom(record.Reserialized, rowStart, bytesPerRow)), width), + fitLine(strings.Repeat(" ", hexRowCaretColumn(record.DiffOffset, bytesPerRow))+ + m.theme.Err.Render("^^ first difference at offset "+itoa(record.DiffOffset)), width), + } + if rows > len(essential) { + // Room to breathe: separate the previews from the dumps. + spaced := append([]string{essential[0], essential[1], strings.Repeat(" ", width)}, essential[2:]...) + return padRows(spaced, width, rows) + } + if rows < len(essential) { + // Fewer rows than load-bearing lines, which the geometry produces whenever the detail + // column bottoms out at its six-row minimum — a hundred by twenty-four with a run panel + // is one such size. padRows keeps the HEAD of what it is given, so leaving this to it + // dropped the caret, which is the offset, which is the entire finding. The previews go + // instead: they are a convenience, and the payload is in the dump above the caret + // either way. + return padRows(essential[len(essential)-rows:], width, rows) + } + return padRows(essential, width, rows) +} + +// detailBytesLines is the plain hex dump of the captured payload. +func (m Model) detailBytesLines(record Record, rows, width int) []string { + header := fitLine(m.theme.Key.Render("original ")+m.theme.Muted.Render(itoa(len(record.Original))+" bytes"), width) + return padRows(append([]string{header}, m.hexBlock(record.Original, m.detailTop, rows-1, width)...), width, rows) +} + +// detailTreeLines is the parsed message rendered as text. +func (m Model) detailTreeLines(record Record, rows, width int) []string { + if record.Tree == "" { + return padRows([]string{ + fitLine(m.theme.Muted.Render("packet "+itoa(record.Number)+" did not parse"), width), + fitLine(m.theme.Muted.Render(shorten(record.Reason, width, m.theme.Glyphs.Ellipsis)), width), + }, width, rows) + } + source := splitLines(record.Tree) + if m.detailWrap { + source = wrapLines(source, width) + } + top := clampIndex(m.detailTop, len(source)) + out := make([]string, 0, rows) + for i := top; i < min(top+rows, len(source)); i++ { + out = append(out, fitLine(m.renderTreeLine(shorten(source[i], width, m.theme.Glyphs.Ellipsis)), width)) + } + return padRows(out, width, rows) +} + +// renderTreeLine dims the box-drawing plc4x renders its parse trees with. +// +// The tree arrives already drawn as nested boxes, which is what makes it readable at all. But +// undifferentiated, the frames outweigh what is inside them and a screenful reads as texture +// rather than as structure. Dropping the frames to the chrome colour leaves the field names and +// their values as the only thing at full contrast. +func (m Model) renderTreeLine(line string) string { + var out strings.Builder + var run []rune + runIsFrame := false + flush := func() { + if len(run) == 0 { + return + } + style := m.theme.Value + if runIsFrame { + style = m.theme.Chrome + } + out.WriteString(style.Render(string(run))) + run = run[:0] + } + for _, r := range line { + if frame := isBoxDrawing(r); frame != runIsFrame { + flush() + runIsFrame = frame + } + run = append(run, r) + } + flush() + return out.String() +} + +// isBoxDrawing reports whether a rune is in the Unicode block plc4x draws its trees with. +// +// That block only, deliberately: the ASCII characters a box could be drawn from -- the hyphen, +// the pipe, the plus -- occur in payload text too, and dimming those would dim data. +func isBoxDrawing(r rune) bool { return r >= 0x2500 && r <= 0x257F } + +// hexBlock renders up to rows of a hex dump, starting at a row offset. +func (m Model) hexBlock(data []byte, top, rows, width int) []string { + if rows < 1 { + return nil + } + all := HexDumpLines(data, hexBytesPerRow, 1<<16) + top = clampIndex(top, len(all)) + out := make([]string, 0, rows) + for i := top; i < min(top+rows, len(all)); i++ { + out = append(out, fitLine(m.renderHexLine(all[i]), width)) + } + return out +} + +// renderHexLine dims the scaffolding of a hex dump so the bytes stand out of it. +// +// A dump is three things on one line: where you are, what is there, and what it reads as in +// text. Only the middle one is the data, and drawing all three in the same colour makes the row +// a wall the eye has to parse before it can look anything up. The rest is context. +func (m Model) renderHexLine(line string) string { + offset, rest, split := strings.Cut(line, " ") + if !split { + return m.theme.Value.Render(line) + } + rendered := m.theme.Muted.Render(offset) + m.theme.Value.Render(" ") + data, text, hasText := strings.Cut(rest, "|") + if !hasText { + return rendered + m.theme.Value.Render(rest) + } + return rendered + m.theme.Value.Render(data) + m.theme.Muted.Render("|"+text) +} + +// hexRowFrom renders one row of a hex dump, tolerating an offset past the end of the data. +func hexRowFrom(data []byte, from, bytesPerRow int) string { + if from >= len(data) { + return strings.Repeat(" ", hexRowCaretColumn(0, bytesPerRow)) + "(no more bytes)" + } + return hexRow(data, from, min(from+bytesPerRow, len(data)), bytesPerRow) +} + +// runPanelLines draws the run panel: the phase breadcrumb, the bar, and the counters. +func (m Model) runPanelLines(width, height int) []string { + focused := m.activePane() == paneRun && m.focus != tui.FocusPrompt + inner := max(width-2, 1) + + status := "enter re-run" + if m.run.active { + status = "esc abort" + } + + content := []string{m.runProgressLine(inner)} + if height >= runPanelFull { + content = append(content, m.runCountersLine(inner)) + } + + pane := tui.Pane{ + Title: paneTitle(paneRun) + " " + m.theme.Glyphs.Separator + " " + m.run.label, + Status: status, + Width: width, + Height: height, + Focused: focused, + } + return splitLines(pane.Render(m.theme, strings.Join(content, "\n"))) +} + +// runProgressLine is the spinner, the phase breadcrumb, the bar and the packet count. +func (m Model) runProgressLine(width int) string { + spinner := " " + if m.run.active { + frames := m.theme.Glyphs.Spinner + if len(frames) > 0 { + spinner = frames[m.spinnerFrame%len(frames)] + } + } else if m.run.err != nil { + spinner = m.theme.Glyphs.Err + } else if m.run.label != "" { + spinner = m.theme.Glyphs.Ok + } + + breadcrumb := m.phaseBreadcrumb() + count := itoa(m.run.current) + " / " + itoa(m.run.total) + " pkt" + fraction := 0.0 + if m.run.total > 0 { + fraction = min(float64(m.run.current)/float64(m.run.total), 1) + } + percent := itoa(int(fraction*100+0.5)) + "%" + + // The bar takes whatever the fixed parts leave. Below a usable minimum it is dropped + // entirely rather than drawn as a stub that says nothing. The twelve is the four literal + // gaps this line is assembled from; it is spelled out rather than hidden in a magic number + // because getting it wrong clips the packet count off the right-hand end. + const gaps = 1 + 1 + 3 + 3 + 3 + 1 + fixed := lipgloss.Width(spinner) + lipgloss.Width(breadcrumb) + len(percent) + len(count) + gaps + barWidth := width - fixed + bar := "" + if barWidth >= 8 { + bar = m.progressBar(barWidth, fraction) + " " + } + return fitLine(" "+m.theme.Accent.Render(spinner)+" "+breadcrumb+" "+bar+ + m.theme.Value.Render(percent)+" "+m.theme.Muted.Render(count), width) +} + +// phaseBreadcrumb renders "index > filter > analyze" with the current phase picked out. +func (m Model) phaseBreadcrumb() string { + parts := make([]string, 0, len(Phases)) + for _, phase := range Phases { + if phase == m.run.phase { + parts = append(parts, m.theme.Accent.Render(phase)) + continue + } + parts = append(parts, m.theme.Muted.Render(phase)) + } + return strings.Join(parts, m.theme.Chrome.Render(" "+m.theme.Glyphs.Selected+" ")) +} + +// progressBar draws a bar of exactly width cells. +// +// It is drawn from the theme's glyphs rather than with bubbles/progress. That package needs +// github.com/charmbracelet/harmonica for its spring animation, which is not in this module's +// go.sum, and its colouring cannot be switched off, which would fight the no-colour theme the +// golden tests render with. The glyph set already carries a matched pair of full and empty +// characters for exactly this. +func (m Model) progressBar(width int, fraction float64) string { + if width < 1 { + return "" + } + filled := clamp(int(fraction*float64(width)+0.5), 0, width) + return m.theme.Accent.Render(strings.Repeat(m.theme.Glyphs.ProgressFull, filled)) + + m.theme.Chrome.Render(strings.Repeat(m.theme.Glyphs.ProgressEmpty, width-filled)) +} + +// runCountersLine is the second row of the run panel. +func (m Model) runCountersLine(width int) string { + counters := m.run.counters + pairs := [][2]string{ + {"parsed", itoa(counters.Parsed)}, + {"parse-fail", itoa(counters.ParseFail)}, + {"serialize-fail", itoa(counters.SerializeFail)}, + {"compare-fail", itoa(counters.CompareFail)}, + } + // The labels are shed from the right when the row is narrow, because the leftmost counter + // is the one that says whether anything worked at all. + for keep := len(pairs); keep > 0; keep-- { + parts := make([]string, 0, keep) + for _, pair := range pairs[:keep] { + style := m.theme.Value + if pair[1] != "0" && pair[0] != "parsed" { + style = m.theme.Err + } + parts = append(parts, m.theme.Key.Render(pair[0])+" "+style.Render(pair[1])) + } + line := " " + strings.Join(parts, " ") + if lipgloss.Width(line) <= width { + return fitLine(line, width) + } + } + return fitLine(" "+m.theme.Key.Render("parsed ")+m.theme.Value.Render(itoa(counters.Parsed)), width) +} + +// formatOffset renders how long after the first packet this one arrived, as mm:ss.mmm. +func formatOffset(offset time.Duration) string { + if offset < 0 { + offset = 0 + } + totalMillis := int64(offset / 1e6) + minutes := totalMillis / 60000 + seconds := totalMillis % 60000 / 1000 + millis := totalMillis % 1000 + return pad2(minutes) + ":" + pad2(seconds) + "." + pad3(millis) +} + +// pad2 and pad3 zero-pad a number to a fixed number of digits. +func pad2(value int64) string { + if value < 10 { + return "0" + strconv.FormatInt(value, 10) + } + return strconv.FormatInt(value, 10) +} + +func pad3(value int64) string { + switch { + case value < 10: + return "00" + strconv.FormatInt(value, 10) + case value < 100: + return "0" + strconv.FormatInt(value, 10) + default: + return strconv.FormatInt(value, 10) + } +} + +// zipColumns places two blocks side by side, line for line, at exact widths. +// +// This is done by hand rather than with lipgloss.JoinHorizontal because the widths here are a +// budget that has to add up: a column that is one cell wider than it claimed would push the +// column beside it off the screen, and a zip that pads and clips every line makes that +// impossible rather than merely unlikely. +func zipColumns(left string, leftWidth int, right string, rightWidth, height int) string { + leftLines := fitBlock(left, leftWidth, height) + rightLines := fitBlock(right, rightWidth, height) + out := make([]string, height) + for i := range height { + out[i] = leftLines[i] + rightLines[i] + } + return strings.Join(out, "\n") +} + +// fitBlock forces a block to exactly height lines of exactly width cells. +func fitBlock(block string, width, height int) []string { + source := strings.Split(block, "\n") + out := make([]string, height) + for i := range height { + line := "" + if i < len(source) { + line = source[i] + } + out[i] = fitLine(line, width) + } + return out +} + +// padRows pads a slice of rendered rows out to exactly count rows. +func padRows(rows []string, width, count int) []string { + for len(rows) < count { + rows = append(rows, strings.Repeat(" ", width)) + } + return rows[:count] +} + +// padOnto pads a line out to width in a style, so a banded row's background runs to the edge +// of the screen rather than stopping where its text does. It truncates like fitLine. +func padOnto(style lipgloss.Style, line string, width int) string { + if gap := width - lipgloss.Width(line); gap > 0 { + return line + style.Render(strings.Repeat(" ", gap)) + } + return fitLine(line, width) +} + +// fitLine forces one line to exactly width cells, measured in display cells so that styled +// text and wide glyphs are counted correctly. +func fitLine(line string, width int) string { + if width <= 0 { + return "" + } + actual := lipgloss.Width(line) + switch { + case actual == width: + return line + case actual < width: + return line + strings.Repeat(" ", width-actual) + default: + return lipgloss.NewStyle().MaxWidth(width).Render(line) + } +} + +// pad right-pads plain text to a width. It is for text with no styling; fitLine is for text +// that may carry escape sequences. +func pad(text string, width int) string { + if width <= 0 { + return "" + } + if length := lipgloss.Width(text); length < width { + return text + strings.Repeat(" ", width-length) + } + return text +} + +// shorten truncates text to width cells, marking that it did so. +func shorten(text string, width int, ellipsis string) string { + if width <= 0 { + return "" + } + if lipgloss.Width(text) <= width { + return text + } + runes := []rune(text) + keep := min(max(width-lipgloss.Width(ellipsis), 0), len(runes)) + return string(runes[:keep]) + ellipsis +} + +// wrapLines hard-wraps lines at a width, for the detail pane's wrap toggle. +func wrapLines(lines []string, width int) []string { + if width < 1 { + return lines + } + var out []string + for _, line := range lines { + runes := []rune(line) + if len(runes) == 0 { + out = append(out, "") + continue + } + for start := 0; start < len(runes); start += width { + out = append(out, string(runes[start:min(start+width, len(runes))])) + } + } + return out +} + +// nonEmpty drops the empty members of a slice, so that an absent badge does not leave a double +// space in the status bar. +func nonEmpty(values []string) []string { + out := values[:0:0] + for _, value := range values { + if value != "" { + out = append(out, value) + } + } + return out +} + +// clamp constrains value to [lowest, highest]. +func clamp(value, lowest, highest int) int { + return min(max(value, lowest), highest) +} + +// paneTitle prefixes a pane's name with the key that jumps to it, the way btop does. +// +// The hotkeys were unreachable in practice for two reasons: alt+digit is swallowed by every +// terminal that binds it to switching terminal tabs, and nothing on screen said which number +// belonged to which pane. Drawing the number fixes the second half; accepting a bare digit +// wherever it cannot be text fixes the first. +func paneTitle(p pane) string { + name := p.String() + return paneLabel(p) + " " + strings.ToUpper(name[:1]) + name[1:] +} + +// paneLabel is the bracketed jump key on its own, for a title that carries its own text. +func paneLabel(p pane) string { + return "[" + itoa(int(p)+1) + "]" +} diff --git a/plc4go/tools/plc4xpcapanalyzer/ui/view_test.go b/plc4go/tools/plc4xpcapanalyzer/ui/view_test.go new file mode 100644 index 000000000..d1440dc67 --- /dev/null +++ b/plc4go/tools/plc4xpcapanalyzer/ui/view_test.go @@ -0,0 +1,561 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package ui + +import ( + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/plc4x-extras/plc4go/tools/internal/progress" + "github.com/apache/plc4x-extras/plc4go/tools/internal/tui" +) + +// View tests. Everything renders with the no-colour ASCII theme, so an assertion on the +// content is an assertion on characters and a width is a count of them. + +// sizes are the three the layout has to work at. Eighty by twenty-four is the one the tools +// being replaced were catastrophically broken at, and sixty by twelve is the declared minimum. +var sizes = []tui.Size{ + {Width: 100, Height: 30}, + {Width: 80, Height: 24}, + {Width: 60, Height: 12}, +} + +// render draws a model at a size and returns its lines. +func render(model Model, size tui.Size) []string { + return splitLines(send(model, tea.WindowSizeMsg{Width: size.Width, Height: size.Height}).Render()) +} + +// TestEveryLineIsExactlyTheTerminalWidth is the invariant every other view assertion rests on. +// A line one cell too wide wraps, which pushes every row below it down one and eventually +// pushes the prompt off the bottom. +func TestEveryLineIsExactlyTheTerminalWidth(t *testing.T) { + model := analysed(t, okRecord(1), brokenRecord(2), okRecord(3)) + for _, size := range sizes { + t.Run(size.String(), func(t *testing.T) { + lines := render(model, size) + require.Len(t, lines, size.Height, "the screen must be exactly as tall as the terminal") + for i, line := range lines { + assert.Equal(t, size.Width, lipgloss.Width(line), "line %d is %q", i, line) + } + }) + } +} + +// TestThePromptIsNeverDroppedAndNeverClipped is the rule the responsive design was written +// around. The tview version added its command area to the grid with a zero row and column span +// below a hundred columns, so the input was not drawn at all and the user typed blind. +func TestThePromptIsNeverDroppedAndNeverClipped(t *testing.T) { + model := analysed(t, okRecord(1), brokenRecord(2)) + for _, size := range append(sizes, tui.Size{Width: 60, Height: 12}, tui.Size{Width: 90, Height: 14}) { + t.Run(size.String(), func(t *testing.T) { + sized := send(model, tea.WindowSizeMsg{Width: size.Width, Height: size.Height}) + sized.prompt.SetValue("analyze c-bus cbus-demo.pcap") + lines := splitLines(sized.Render()) + + // The prompt is the second-to-last row, above the help footer. + require.GreaterOrEqual(t, len(lines), 2) + prompt := lines[len(lines)-2] + assert.Contains(t, prompt, "$", "the prompt row must carry the prompt label at %s", size) + assert.Equal(t, size.Width, lipgloss.Width(prompt)) + assert.True(t, strings.Contains(prompt, "analyze"), + "what the user typed has to be visible at %s, got %q", size, prompt) + }) + } +} + +// TestThePromptSurvivesATerminalBelowTheMinimum keeps the one invariant true even where the +// layout has given up on everything else. +func TestThePromptSurvivesATerminalBelowTheMinimum(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, tui.Size{Width: 50, Height: 10}) + model.prompt.SetValue("open") + + lines := splitLines(model.Render()) + require.NotEmpty(t, lines) + assert.Contains(t, strings.Join(lines, "\n"), tui.TooSmallMessage()) + assert.Contains(t, strings.Join(lines, "\n"), "60x12 minimum") + assert.Contains(t, lines[len(lines)-1], "open", "even here the prompt is drawn") +} + +// TestTheStatusBarDegradesInOrder pins the shedding order. The drawn header is about ninety +// cells and does not fit at eighty; what goes first has to be the least useful part. +func TestTheStatusBarDegradesInOrder(t *testing.T) { + state, _ := demoState(t) + state.HostIP = "192.168.178.101" + model := newTestModel(t, state, wide) + + at := func(width int) string { + sized := send(model, tea.WindowSizeMsg{Width: width, Height: 30}) + return sized.statusLine(width) + } + + full := at(120) + assert.Contains(t, full, AppName) + assert.Contains(t, full, AppVersion) + assert.Contains(t, full, "DEMO") + assert.Contains(t, full, "c-bus") + assert.Contains(t, full, "client 192.168.178.101") + assert.Contains(t, full, "pane ") + + eighty := at(80) + assert.NotContains(t, eighty, "pane ", "the focused pane's name is the first thing shed") + assert.Contains(t, eighty, "192.168.178.101") + assert.Contains(t, eighty, "DEMO") + + sixty := at(60) + assert.NotContains(t, sixty, "client ", "the label around the address goes before the address does") + assert.Contains(t, sixty, "192.168.178.101") + assert.Contains(t, sixty, "DEMO") + + forty := at(40) + assert.Contains(t, forty, AppName) + assert.Contains(t, forty, "DEMO", "the demo badge is never shed: taking a demo capture for a real one is the one mistake to prevent") + + for _, width := range []int{120, 100, 90, 80, 70, 60, 50, 40, 30, 20} { + assert.LessOrEqual(t, lipgloss.Width(at(width)), width, "the status bar overflowed at %d", width) + } +} + +// TestTheDemoBadgeIsOnlyShownInDemoMode keeps the badge honest. +func TestTheDemoBadgeIsOnlyShownInDemoMode(t *testing.T) { + state, _ := demoState(t) + state.Demo = false + model := newTestModel(t, state, wide) + assert.NotContains(t, model.Render(), "DEMO") + + state.Demo = true + assert.Contains(t, newTestModel(t, state, wide).Render(), "DEMO") +} + +func TestTheWideLayoutDrawsEveryPane(t *testing.T) { + model := analysed(t, okRecord(1), brokenRecord(2)) + screen := model.Render() + + assert.Contains(t, screen, "Captures", "the sidebar") + assert.Contains(t, screen, "Packets", "the main pane's tab strip") + assert.Contains(t, screen, "Findings") + assert.Contains(t, screen, "Detail", "the detail pane sits beneath the main one") + assert.Contains(t, screen, "Run ", "the run panel") + assert.Contains(t, screen, "DRIVERS") + assert.Contains(t, screen, "OPTIONS") +} + +// TestTheCompactLayoutIsASingleColumnWithOverlays is the eighty-column story. +func TestTheCompactLayoutIsASingleColumnWithOverlays(t *testing.T) { + model := analysed(t, okRecord(1), brokenRecord(2)) + model = send(model, tea.WindowSizeMsg{Width: compact.Width, Height: compact.Height}) + require.Equal(t, tui.ModeCompact, model.layout.Mode) + + screen := model.Render() + assert.Contains(t, screen, "Packets") + assert.NotContains(t, screen, "DRIVERS", "the sidebar is an overlay here, not a column") + + opened := send(model, modKey('1', tea.ModAlt)) + assert.Contains(t, opened.Render(), "DRIVERS", "and alt+1 reaches it") + + detail := send(model, modKey('3', tea.ModAlt)) + assert.Contains(t, detail.Render(), "Detail") +} + +// TestAnOverlayFillsTheBodyWithoutOverflowing keeps the compact-mode overlays honest at the +// sizes the design was never drawn at. +func TestAnOverlayFillsTheBodyWithoutOverflowing(t *testing.T) { + model := analysed(t, okRecord(1), brokenRecord(2)) + for _, size := range []tui.Size{{Width: 80, Height: 24}, {Width: 60, Height: 12}} { + for _, overlay := range []struct { + name string + key tea.KeyPressMsg + }{{"captures", modKey('1', tea.ModAlt)}, {"detail", modKey('3', tea.ModAlt)}} { + t.Run(size.String()+"/"+overlay.name, func(t *testing.T) { + sized := send(model, tea.WindowSizeMsg{Width: size.Width, Height: size.Height}) + sized = send(sized, overlay.key) + lines := splitLines(sized.Render()) + require.Len(t, lines, size.Height) + for i, line := range lines { + assert.Equal(t, size.Width, lipgloss.Width(line), "line %d is %q", i, line) + } + assert.Contains(t, lines[len(lines)-2], "$", "the prompt keeps its row under an overlay") + }) + } + } +} + +// TestNoPaneIsFocusableWithoutBeingDrawn is the invariant the geometry exists to hold. A pane +// tab can reach but the renderer skips is a keyboard black hole. +func TestNoPaneIsFocusableWithoutBeingDrawn(t *testing.T) { + model := analysed(t, okRecord(1)) + for _, size := range append(sizes, tui.Size{Width: 90, Height: 14}, tui.Size{Width: 100, Height: 16}) { + t.Run(size.String(), func(t *testing.T) { + sized := send(model, tea.WindowSizeMsg{Width: size.Width, Height: size.Height}) + geometry := sized.geometry() + assert.Equal(t, geometry.SidebarWidth > 0, sized.paneVisible(paneCaptures)) + assert.Equal(t, geometry.DetailHeight > 0, sized.paneVisible(paneDetail)) + assert.Equal(t, geometry.RunHeight > 0, sized.paneVisible(paneRun)) + }) + } +} + +func TestTheFocusedPaneIsVisiblyDistinct(t *testing.T) { + model := analysed(t, okRecord(1)) + // Colour is off in these tests, so focus has to be visible in the characters themselves. + // tui.Pane draws the focused pane with the same border, so the difference has to come from + // somewhere the reader can see: the selection marker in the focused pane's rows. + atPrompt := send(model, tea.WindowSizeMsg{Width: wide.Width, Height: wide.Height}) + inPane := send(atPrompt, modKey(tea.KeyTab, tea.ModShift)) + + assert.NotEqual(t, atPrompt.Render(), inPane.Render(), + "moving the keyboard into a pane has to change what is drawn, or the user cannot tell where it went") + assert.Contains(t, inPane.Render(), tui.ASCIIGlyphs().Selected) +} + +func TestTheTabStripMarksTheActiveTab(t *testing.T) { + model := analysed(t, okRecord(1)) + marker := tui.ASCIIGlyphs().Selected + + assert.Contains(t, model.tabStrip(), marker+"Packets") + assert.Equal(t, tabLog, send(send(model, modKey(tea.KeyTab, tea.ModShift)), charKey(']')).tab) + assert.Contains(t, send(send(model, modKey(tea.KeyTab, tea.ModShift)), charKey(']')).tabStrip(), marker+"Log") +} + +func TestTheMainPaneCarriesTheCountAndTheIssues(t *testing.T) { + model := analysed(t, okRecord(1), brokenRecord(2), okRecord(3)) + assert.Contains(t, model.Render(), "3 | 1 issues") + + clean := analysed(t, okRecord(1)) + assert.Contains(t, clean.Render(), "no issues") +} + +// TestEmptyPanesNameTheCommandThatFillsThem is the no-dead-end rule. An empty pane that only +// says "empty" leaves a first-time user with nowhere to go. +func TestEmptyPanesNameTheCommandThatFillsThem(t *testing.T) { + pinGlobals(t) + state := NewState(t.TempDir(), NewConfig()) + model := newTestModel(t, state, wide) + screen := model.Render() + + assert.Contains(t, screen, "no captures open") + assert.Contains(t, screen, "open ") + assert.Contains(t, screen, "no packets analysed yet") + assert.Contains(t, screen, "analyze c-bus ") +} + +func TestTheEmptyPacketPaneUsesTheOpenCapture(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, wide) + assert.Contains(t, model.Render(), "analyze c-bus "+DemoCaptureName) +} + +func TestTheEmptyLogSaysWhatWillAppearThere(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, wide) + model = send(model, modKey(tea.KeyTab, tea.ModShift)) + model = send(model, charKey(']')) + require.Equal(t, tabLog, model.tab) + assert.Contains(t, model.Render(), "nothing logged yet") +} + +func TestTheDetailPaneSaysHowToFillIt(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, wide) + assert.Contains(t, model.Render(), "no packet selected") +} + +// TestTheDetailPaneShowsTheByteDifference is the most valuable thing on the screen, so it is +// pinned in detail. +func TestTheDetailPaneShowsTheByteDifference(t *testing.T) { + model := analysed(t, brokenRecord(1)) + screen := model.Render() + + assert.Contains(t, screen, "original") + assert.Contains(t, screen, "reserialized") + assert.Contains(t, screen, "4 bytes differ") + assert.Contains(t, screen, "first difference at offset 2") + assert.Contains(t, screen, "Detail | packet 1") + + // The caret has to line up with the differing byte in both dumps. + lines := splitLines(screen) + var caret, first, second string + for i, line := range lines { + if strings.Contains(line, "first difference at offset") { + caret, first, second = line, lines[i-2], lines[i-1] + break + } + } + require.NotEmpty(t, caret) + column := strings.Index(caret, "^^") + require.Positive(t, column) + assert.NotEqual(t, first[column:column+2], second[column:column+2], + "the caret must point at bytes that actually differ:\n%s\n%s\n%s", first, second, caret) +} + +// TestTheCaretSurvivesAShortDetailPane pins which lines go when the detail column bottoms out +// at its six-row minimum, which it does at every height from twenty to twenty-six once a run +// panel is on screen — a hundred by twenty-four included. Padding kept the HEAD of the block, +// so what got dropped was the caret: the offset of the first differing byte, which is the +// entire finding. +func TestTheCaretSurvivesAShortDetailPane(t *testing.T) { + model := analysed(t, brokenRecord(1)) + for _, height := range []int{20, 22, 24, 26, 30, 40} { + t.Run(itoa(height), func(t *testing.T) { + sized := send(model, tea.WindowSizeMsg{Width: 100, Height: height}) + require.Positive(t, sized.geometry().DetailHeight, "the detail pane has to be drawn at all") + assert.Contains(t, sized.Render(), "first difference at offset 2") + }) + } +} + +func TestTheDetailPaneSaysWhenTheBytesMatch(t *testing.T) { + model := analysed(t, okRecord(1)) + assert.Contains(t, model.Render(), "identical") +} + +func TestTheDetailPaneSwitchesToBytesAndTree(t *testing.T) { + model := analysed(t, okRecord(1)) + model = send(model, modKey(tea.KeyTab, tea.ModShift)) + + bytesView := send(model, charKey('b')).Render() + assert.Contains(t, bytesView, "4 bytes") + assert.Contains(t, bytesView, "00000000") + + treeView := send(model, charKey('t')).Render() + assert.Contains(t, treeView, "CBusMessageToServer") +} + +func TestTheRunPanelShowsThePhasesTheBarAndTheCounters(t *testing.T) { + state, demo := demoState(t) + model := newTestModel(t, state, wide) + request := state.RequestFor(demo.Protocol, demo.Path) + model = send(model, startAnalysisMsg{Request: request}) + model = send(model, progressMsg{Update: progress2(3741, 6128, PhaseAnalyze)}) + model = send(model, recordsMsg{Records: []Record{okRecord(1), brokenRecord(2)}}) + + screen := model.Render() + assert.Contains(t, screen, "Run | analyze c-bus "+DemoCaptureName) + for _, phase := range Phases { + assert.Contains(t, screen, phase, "the breadcrumb must name every phase") + } + assert.Contains(t, screen, "3741 / 6128 pkt") + assert.Contains(t, screen, "61%") + assert.Contains(t, screen, strings.Repeat(tui.ASCIIGlyphs().ProgressFull, 4), "the bar has to be drawn") + assert.Contains(t, screen, "parse-fail") + assert.Contains(t, screen, "compare-fail") + assert.Contains(t, screen, "esc abort") +} + +func TestTheRunPanelOffersARerunOnceItIsFinished(t *testing.T) { + model := analysed(t, okRecord(1)) + assert.Contains(t, model.Render(), "enter re-run") +} + +// TestTheRunPanelSurvivesANarrowTerminal keeps the drawn panel from overflowing at the sizes +// it was never sketched at. +func TestTheRunPanelSurvivesANarrowTerminal(t *testing.T) { + state, demo := demoState(t) + model := newTestModel(t, state, wide) + model = send(model, startAnalysisMsg{Request: state.RequestFor(demo.Protocol, demo.Path)}) + model = send(model, progressMsg{Update: progress2(50, 100, PhaseFilter)}) + + for _, size := range sizes { + t.Run(size.String(), func(t *testing.T) { + lines := render(model, size) + joined := strings.Join(lines, "\n") + assert.Contains(t, joined, "50 / 100 pkt", "the packet count must never be the thing that gets clipped") + for i, line := range lines { + assert.Equal(t, size.Width, lipgloss.Width(line), "line %d", i) + } + }) + } +} + +func TestTheProgressBarIsExactlyTheWidthAsked(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, wide) + for _, width := range []int{1, 5, 8, 20, 60} { + for _, fraction := range []float64{0, 0.5, 1} { + assert.Equal(t, width, lipgloss.Width(model.progressBar(width, fraction)), + "width %d fraction %v", width, fraction) + } + } + assert.Empty(t, model.progressBar(0, 0.5)) +} + +func TestTheToastLineCarriesTheDismissHint(t *testing.T) { + model := analysed(t, okRecord(1)) + require.NotEmpty(t, model.toast) + lines := splitLines(model.Render()) + toast := lines[len(lines)-3] + assert.Contains(t, toast, model.toast) + assert.Contains(t, toast, "esc dismiss") +} + +func TestTheFooterAlwaysKeepsTheWayOut(t *testing.T) { + model := analysed(t, okRecord(1)) + for _, size := range sizes { + t.Run(size.String(), func(t *testing.T) { + lines := render(model, size) + footer := lines[len(lines)-1] + assert.Contains(t, footer, "quit", "whatever else is shed, the user keeps the way out") + assert.Equal(t, size.Width, lipgloss.Width(footer)) + }) + } +} + +func TestTheExpandedHelpIsDrawnOverTheBodyRatherThanTakingThePromptsRow(t *testing.T) { + model := analysed(t, okRecord(1)) + model = send(model, tea.KeyPressMsg{Code: tea.KeyF1}) + lines := splitLines(model.Render()) + + require.Len(t, lines, wide.Height) + assert.Contains(t, lines[len(lines)-2], "$", "the prompt keeps its row while the help is up") + for i, line := range lines { + assert.Equal(t, wide.Width, lipgloss.Width(line), "line %d", i) + } +} + +func TestTheCompletionListIsDrawnOverTheBody(t *testing.T) { + state, _ := demoState(t) + model := newTestModel(t, state, wide) + // Editing the line opens the list on its own; tab would accept the highlighted candidate + // and close it again, which is exactly what tab is for. + for _, r := range "analyz" { + model = send(model, charKey(r)) + } + require.True(t, model.prompt.CompletionOpen()) + + lines := splitLines(model.Render()) + require.Len(t, lines, wide.Height) + assert.Contains(t, strings.Join(lines, "\n"), "analyze") + assert.Contains(t, lines[len(lines)-2], "$") + for i, line := range lines { + assert.Equal(t, wide.Width, lipgloss.Width(line), "line %d", i) + } +} + +func TestThePacketTableShedsColumnsRatherThanOverflowing(t *testing.T) { + assert.Len(t, packetLayout(120), 6, "everything fits at 120") + narrow := packetLayout(46) + names := make([]string, 0, len(narrow)) + for _, column := range narrow { + names = append(names, column.title) + } + assert.Contains(t, names, "no.") + assert.Contains(t, names, "message") + assert.Contains(t, names, "verdict") + assert.NotContains(t, names, "proto", "the protocol is the same for every row, so it goes first") +} + +func TestFormatOffset(t *testing.T) { + for duration, want := range map[time.Duration]string{ + 0: "00:00.000", + time.Millisecond: "00:00.001", + 1500 * time.Millisecond: "00:01.500", + 90*time.Second + 250*time.Millisecond: "01:30.250", + -time.Second: "00:00.000", + } { + assert.Equal(t, want, formatOffset(duration), "duration %v", duration) + } +} + +func TestShortenMarksThatItTruncated(t *testing.T) { + assert.Equal(t, "abc", shorten("abc", 3, ".")) + assert.Equal(t, "ab.", shorten("abcd", 3, ".")) + assert.Empty(t, shorten("abc", 0, ".")) +} + +func TestSidebarRowsCoverCapturesDriversAndOptions(t *testing.T) { + model := analysed(t, okRecord(1)) + kinds := map[sidebarKind]int{} + for _, row := range model.sidebarRows() { + kinds[row.kind]++ + } + assert.Positive(t, kinds[sidebarCapture]) + assert.Equal(t, len(DriverNames), kinds[sidebarDriver]) + assert.Positive(t, kinds[sidebarOption]) + assert.Positive(t, kinds[sidebarHeader]) +} + +// progress2 builds a progress update, kept short because the tests above use several. +func progress2(current, total int, phase string) progress.Update { + return progress.Update{Current: current, Total: total, Description: phase} +} + +// TestTheCursorMovesInsideTheWindowRatherThanDraggingTheList is the regression test for a +// scroll window derived from the cursor. Deriving it pinned the selection to the bottom +// visible row, so every press of up or down scrolled the whole list by one and the highlighted +// packet was never anywhere else. +func TestTheCursorMovesInsideTheWindowRatherThanDraggingTheList(t *testing.T) { + // Enough packets to overflow the pane: with a list that fits, the window never scrolls and + // a derived window is indistinguishable from a stored one. + records := make([]Record, 0, 60) + for i := 1; i <= 60; i++ { + records = append(records, okRecord(i)) + } + model := analysed(t, records...) + require.Greater(t, len(model.visible), 40, "the fixture must not fit in the pane") + + // Leave the prompt first: the bare pane-jump keys are pane-scoped and inert while the + // prompt has the keyboard, which is the scoping rule working as intended. + model = send(model, modKey(tea.KeyTab, tea.ModShift)) + model = send(model, charKey('2')) + require.Equal(t, paneMain, model.activePane(), "the packet pane must have the keyboard") + startCursor, startTop := model.cursor, model.packetTop + + model = send(model, charKey('k')) + assert.Equal(t, startCursor-1, model.cursor, "up must move the cursor") + assert.Equal(t, startTop, model.packetTop, + "the window must stay put while the cursor is still inside it; a derived window would move with it") + + model = send(model, charKey('k')) + assert.Equal(t, startCursor-2, model.cursor) + assert.Equal(t, startTop, model.packetTop) +} + +// TestTheWindowScrollsOnlyWhenTheCursorReachesAnEdge is the other half of the rule. +func TestTheWindowScrollsOnlyWhenTheCursorReachesAnEdge(t *testing.T) { + records := make([]Record, 0, 40) + for i := 1; i <= 40; i++ { + records = append(records, okRecord(i)) + } + model := analysed(t, records...) + model = send(model, modKey(tea.KeyTab, tea.ModShift)) + model = send(model, charKey('2')) + require.Equal(t, paneMain, model.activePane()) + + // Walk to the top; the window has to follow once the cursor passes its first row. + for range len(model.visible) { + model = send(model, charKey('k')) + } + assert.Zero(t, model.cursor, "walking up far enough must reach the first packet") + assert.Zero(t, model.packetTop, "and the window must have followed it to the top") + + // Walk back down; the window follows again at the far edge. + for range len(model.visible) { + model = send(model, charKey('j')) + } + assert.Equal(t, len(model.visible)-1, model.cursor) + assert.Positive(t, model.packetTop, "the window must have scrolled to keep the last packet visible") +} diff --git a/plc4j/examples/hello-ads-telemetry/pom.xml b/plc4j/examples/hello-ads-telemetry/pom.xml index f98750fd2..2340216e4 100644 --- a/plc4j/examples/hello-ads-telemetry/pom.xml +++ b/plc4j/examples/hello-ads-telemetry/pom.xml @@ -17,14 +17,12 @@ specific language governing permissions and limitations under the License. --> - - - 4.0.0 + org.apache.plc4x.examples plc4j-examples - 0.13.0-SNAPSHOT + 1.0.0-SNAPSHOT plc4j-examples-hello-ads-telemetry @@ -32,7 +30,7 @@ Hello world application using PLC4X to read ADS device telemetry. - 2024-02-16T14:53:02Z + 2026-09-20T15:22:10Z org.apache.plc4x.java.examples.helloads.telemetry.HelloAdsTelemetry @@ -40,7 +38,7 @@ org.apache.plc4x plc4j-api - 0.13.0-SNAPSHOT + ${plc4x.version} diff --git a/plc4j/examples/hello-ads-telemetry/src/main/java/org/apache/plc4x/java/examples/helloads/telemetry/HelloAdsEtherCatTelemetry.java b/plc4j/examples/hello-ads-telemetry/src/main/java/org/apache/plc4x/java/examples/helloads/telemetry/HelloAdsEtherCatTelemetry.java index 6b6ba12c0..0671478b7 100644 --- a/plc4j/examples/hello-ads-telemetry/src/main/java/org/apache/plc4x/java/examples/helloads/telemetry/HelloAdsEtherCatTelemetry.java +++ b/plc4j/examples/hello-ads-telemetry/src/main/java/org/apache/plc4x/java/examples/helloads/telemetry/HelloAdsEtherCatTelemetry.java @@ -46,7 +46,7 @@ protected void outputEtherCatData(String remoteIp, String localAmsNetId, String Map devices = new HashMap<>(); // The AmsNetId of the PLC usually is {ip}.1.1 and that of the EtherCAT master is {ip}.3.1 // The port number equals the EtherCAT address. For the EtherCAT master, this port is 0xFFFF = 65535 - try (PlcConnection connection = PlcDriverManager.getDefault().getConnectionManager().getConnection(String.format("ads:tcp://%s?target-ams-net-id=%s&target-ams-port=65535&source-ams-net-id=%s&source-ams-port=65534&load-symbol-and-data-type-tables=false", remoteIp, remoteAmsNetId, localAmsNetId))) { + try (PlcConnection connection = PlcDriverManager.getDefault().getConnectionFactory().getConnection(String.format("ads:tcp://%s?target-ams-net-id=%s&target-ams-port=65535&source-ams-net-id=%s&source-ams-port=65534&load-symbol-and-data-type-tables=false", remoteIp, remoteAmsNetId, localAmsNetId))) { PlcReadRequest manufacturerDeviceName1 = connection.readRequestBuilder().addTagAddress("manufacturerDeviceName", getAddress(EtherCatMasterConstants.ManufacturerDeviceName)).build(); PlcReadResponse plcReadResponse = manufacturerDeviceName1.execute().get(); System.out.println(plcReadResponse.getString("manufacturerDeviceName")); @@ -91,7 +91,7 @@ protected void outputEtherCatData(String remoteIp, String localAmsNetId, String int deviceIndex = device.getKey(); EtherCatDevice etherCatDevice = device.getValue(); logger.info(" - Connecting with device {} on EtherCAT address {}", deviceIndex, 1001); - try (PlcConnection etherCatConnection = PlcDriverManager.getDefault().getConnectionManager().getConnection(String.format("ads:tcp://%s?targetAmsNetId=%s&targetAmsPort=%d&sourceAmsNetId=%s&sourceAmsPort=65534&load-symbol-and-data-type-tables=false", remoteIp, remoteAmsNetId, etherCatDevice.getEtherCatAddress(), localAmsNetId))) { + try (PlcConnection etherCatConnection = PlcDriverManager.getDefault().getConnectionFactory().getConnection(String.format("ads:tcp://%s?target-ams-net-id=%s&target-ams-port=%d&source-ams-net-id=%s&source-ams-port=65534&load-symbol-and-data-type-tables=false", remoteIp, remoteAmsNetId, etherCatDevice.getEtherCatAddress(), localAmsNetId))) { String etherCatAddressAddress = String.format("0x%08X/0x%08X:%s", AoEGroupIndex, 0x60000001, PlcValueType.BOOL.name()); PlcReadRequest build = etherCatConnection.readRequestBuilder() .addTagAddress("Channel 1", etherCatAddressAddress) diff --git a/plc4j/examples/hello-ads-telemetry/src/main/java/org/apache/plc4x/java/examples/helloads/telemetry/HelloAdsTelemetry.java b/plc4j/examples/hello-ads-telemetry/src/main/java/org/apache/plc4x/java/examples/helloads/telemetry/HelloAdsTelemetry.java index 3f1cefa3a..2624606e2 100644 --- a/plc4j/examples/hello-ads-telemetry/src/main/java/org/apache/plc4x/java/examples/helloads/telemetry/HelloAdsTelemetry.java +++ b/plc4j/examples/hello-ads-telemetry/src/main/java/org/apache/plc4x/java/examples/helloads/telemetry/HelloAdsTelemetry.java @@ -43,7 +43,7 @@ public static void main(String[] args) { String remoteIp = args[0]; String localIp = args[1]; - try (PlcConnection connection = PlcDriverManager.getDefault().getConnectionManager().getConnection(String.format("ads:tcp://%s?target-ams-net-id=%s.1.1&target-ams-port=10000&source-ams-net-id=%s.1.1&source-ams-port=65534&load-symbol-and-data-type-tables=false", remoteIp, remoteIp, localIp))) { + try (PlcConnection connection = PlcDriverManager.getDefault().getConnectionFactory().getConnection(String.format("ads:tcp://%s?target-ams-net-id=%s.1.1&target-ams-port=10000&source-ams-net-id=%s.1.1&source-ams-port=65534&load-symbol-and-data-type-tables=false", remoteIp, remoteIp, localIp))) { // Load the number of modules: int numModules = connection.readRequestBuilder().addTagAddress("numberOfModules", "0x0000F302/0xF0200000:UINT").build().execute().get().getInteger("numberOfModules"); diff --git a/plc4j/examples/hello-cloud-azure/pom.xml b/plc4j/examples/hello-cloud-azure/pom.xml index 6fd29701f..f9120629a 100644 --- a/plc4j/examples/hello-cloud-azure/pom.xml +++ b/plc4j/examples/hello-cloud-azure/pom.xml @@ -17,13 +17,12 @@ specific language governing permissions and limitations under the License. --> - - 4.0.0 + org.apache.plc4x.examples plc4j-examples - 0.13.0-SNAPSHOT + 1.0.0-SNAPSHOT plc4j-examples-hello-cloud-azure @@ -34,20 +33,20 @@ - 2024-02-16T14:53:02Z + 2026-09-20T15:22:10Z org.apache.plc4x plc4j-api - 0.13.0-SNAPSHOT + ${plc4x.version} com.microsoft.azure.sdk.iot iot-device-client - 2.5.0 + 2.5.1 diff --git a/plc4j/examples/hello-cloud-azure/src/main/java/org/apache/plc4x/java/examples/cloud/azure/S7PlcToAzureIoTHubSample.java b/plc4j/examples/hello-cloud-azure/src/main/java/org/apache/plc4x/java/examples/cloud/azure/S7PlcToAzureIoTHubSample.java index 1c97a8468..244980e29 100644 --- a/plc4j/examples/hello-cloud-azure/src/main/java/org/apache/plc4x/java/examples/cloud/azure/S7PlcToAzureIoTHubSample.java +++ b/plc4j/examples/hello-cloud-azure/src/main/java/org/apache/plc4x/java/examples/cloud/azure/S7PlcToAzureIoTHubSample.java @@ -54,7 +54,7 @@ public static void main(String[] args) throws Exception { // Open both a connection to the remote PLC and the cloud service. DeviceClient client = new DeviceClient(options.getIotHubConnectionString(), IotHubClientProtocol.MQTT); - try (PlcConnection plcConnection = PlcDriverManager.getDefault().getConnectionManager().getConnection(options.getPlc4xConnectionString())) { + try (PlcConnection plcConnection = PlcDriverManager.getDefault().getConnectionFactory().getConnection(options.getPlc4xConnectionString())) { LOGGER.info("Connected"); diff --git a/plc4j/examples/hello-cloud-google/pom.xml b/plc4j/examples/hello-cloud-google/pom.xml index 8636f04b1..72fbe74ad 100644 --- a/plc4j/examples/hello-cloud-google/pom.xml +++ b/plc4j/examples/hello-cloud-google/pom.xml @@ -17,13 +17,12 @@ specific language governing permissions and limitations under the License. --> - - 4.0.0 + org.apache.plc4x.examples plc4j-examples - 0.13.0-SNAPSHOT + 1.0.0-SNAPSHOT plc4j-examples-hello-cloud-google @@ -34,14 +33,14 @@ - 2024-02-16T14:53:02Z + 2026-09-20T15:22:10Z org.apache.plc4x plc4j-api - 0.13.0-SNAPSHOT + ${plc4x.version} @@ -52,12 +51,12 @@ joda-time joda-time - 2.12.7 + 2.14.2 io.jsonwebtoken jjwt-api - 0.12.6 + 0.13.0 commons-cli diff --git a/plc4j/examples/hello-cloud-google/src/main/java/org/apache/plc4x/java/examples/cloud/google/S7PlcToGoogleIoTCoreSample.java b/plc4j/examples/hello-cloud-google/src/main/java/org/apache/plc4x/java/examples/cloud/google/S7PlcToGoogleIoTCoreSample.java index 2f7943ef1..1cacca710 100644 --- a/plc4j/examples/hello-cloud-google/src/main/java/org/apache/plc4x/java/examples/cloud/google/S7PlcToGoogleIoTCoreSample.java +++ b/plc4j/examples/hello-cloud-google/src/main/java/org/apache/plc4x/java/examples/cloud/google/S7PlcToGoogleIoTCoreSample.java @@ -236,7 +236,7 @@ public static void main(String[] args) throws Exception { // Connect to Plc logger.info("Connecting to Plc"); - try (PlcConnection plcConnection = PlcDriverManager.getDefault().getConnectionManager().getConnection("s7://10.10.64.20/1/1")) { + try (PlcConnection plcConnection = PlcDriverManager.getDefault().getConnectionFactory().getConnection("s7://10.10.64.20/1/1")) { logger.info("Connected"); PlcReadRequest readRequest = plcConnection.readRequestBuilder().addTagAddress("outputs", "OUTPUTS/0").build(); diff --git a/plc4j/examples/hello-connectivity-mqtt/pom.xml b/plc4j/examples/hello-connectivity-mqtt/pom.xml index 1c2863c3d..738e8190c 100644 --- a/plc4j/examples/hello-connectivity-mqtt/pom.xml +++ b/plc4j/examples/hello-connectivity-mqtt/pom.xml @@ -17,14 +17,12 @@ specific language governing permissions and limitations under the License. --> - - - 4.0.0 + org.apache.plc4x.examples plc4j-examples - 0.13.0-SNAPSHOT + 1.0.0-SNAPSHOT plc4j-examples-hello-connectivity-mqtt @@ -32,7 +30,7 @@ Sample application, that reads data from a connected PLC and publishes this information via MQTT. - 2024-02-16T14:53:02Z + 2026-09-20T15:22:10Z org.apache.plc4x.java.examples.connectivity.mqtt.MqttConnector @@ -40,13 +38,13 @@ org.apache.plc4x plc4j-api - 0.13.0-SNAPSHOT + ${plc4x.version} com.hivemq hivemq-mqtt-client - 1.3.3 + 1.3.16 io.reactivex.rxjava2 @@ -57,28 +55,28 @@ com.fasterxml.jackson.dataformat jackson-dataformat-yaml - 2.17.2 + 2.22.1 com.fasterxml.jackson.core jackson-databind - 2.17.2 + 2.22.1 com.fasterxml.jackson.core jackson-annotations - 2.17.2 + 2.22 com.google.code.gson gson - 2.11.0 + 2.14.0 org.apache.commons commons-lang3 - 3.15.0 + 3.20.0 diff --git a/plc4j/examples/hello-connectivity-mqtt/src/main/java/org/apache/plc4x/java/examples/connectivity/mqtt/MqttConnector.java b/plc4j/examples/hello-connectivity-mqtt/src/main/java/org/apache/plc4x/java/examples/connectivity/mqtt/MqttConnector.java index 587f086c7..346d98321 100644 --- a/plc4j/examples/hello-connectivity-mqtt/src/main/java/org/apache/plc4x/java/examples/connectivity/mqtt/MqttConnector.java +++ b/plc4j/examples/hello-connectivity-mqtt/src/main/java/org/apache/plc4x/java/examples/connectivity/mqtt/MqttConnector.java @@ -83,7 +83,7 @@ private void run() throws PlcException { final Single connAckSingle = client.connect().timeout(10, TimeUnit.SECONDS); // Connect to the PLC. - try (PlcConnection plcConnection = PlcDriverManager.getDefault().getConnectionManager().getConnection(config.getPlcConfig().getConnection())) { + try (PlcConnection plcConnection = PlcDriverManager.getDefault().getConnectionFactory().getConnection(config.getPlcConfig().getConnection())) { // Check if this connection support reading of data. if (!plcConnection.getMetadata().isReadSupported()) { diff --git a/plc4j/examples/hello-discovery/pom.xml b/plc4j/examples/hello-discovery/pom.xml index b8ccfeac6..e53892be4 100644 --- a/plc4j/examples/hello-discovery/pom.xml +++ b/plc4j/examples/hello-discovery/pom.xml @@ -17,14 +17,12 @@ specific language governing permissions and limitations under the License. --> - - - 4.0.0 + org.apache.plc4x.examples plc4j-examples - 0.13.0-SNAPSHOT + 1.0.0-SNAPSHOT plc4j-examples-hello-discovery @@ -32,7 +30,7 @@ Hello world application for PLC4X demonstrating the Discovery API. - 2024-02-16T14:53:02Z + 2026-09-20T15:22:10Z org.apache.plc4x.java.examples.hellodiscovery.HelloDiscovery @@ -40,7 +38,7 @@ org.apache.plc4x plc4j-api - 0.13.0-SNAPSHOT + ${plc4x.version} diff --git a/plc4j/examples/hello-influx-data-collection/pom.xml b/plc4j/examples/hello-influx-data-collection/pom.xml index 79b49d3a5..ef7716281 100644 --- a/plc4j/examples/hello-influx-data-collection/pom.xml +++ b/plc4j/examples/hello-influx-data-collection/pom.xml @@ -17,14 +17,12 @@ specific language governing permissions and limitations under the License. --> - - - 4.0.0 + org.apache.plc4x.examples plc4j-examples - 0.13.0-SNAPSHOT + 1.0.0-SNAPSHOT plc4j-examples-hello-influx-data-collection @@ -32,7 +30,7 @@ Data Collection with PLC4X and InfluxDB. - 2024-02-16T14:53:02Z + 2026-09-20T15:22:10Z org.apache.plc4x.java.examples.helloinflux.HelloInflux @@ -40,25 +38,25 @@ org.apache.plc4x plc4j-api - 0.13.0-SNAPSHOT + ${plc4x.version} org.apache.plc4x - plc4j-spi - 0.13.0-SNAPSHOT + plc4j-spi-drivers + ${plc4x.version} com.influxdb influxdb-client-java - 7.1.0 + 8.0.0 org.apache.commons commons-configuration2 - 2.11.0 + 2.15.1 org.slf4j diff --git a/plc4j/examples/hello-influx-data-collection/src/main/java/org/apache/plc4x/java/examples/helloinflux/HelloInflux.java b/plc4j/examples/hello-influx-data-collection/src/main/java/org/apache/plc4x/java/examples/helloinflux/HelloInflux.java index d3ddaf291..5382191e1 100644 --- a/plc4j/examples/hello-influx-data-collection/src/main/java/org/apache/plc4x/java/examples/helloinflux/HelloInflux.java +++ b/plc4j/examples/hello-influx-data-collection/src/main/java/org/apache/plc4x/java/examples/helloinflux/HelloInflux.java @@ -34,8 +34,8 @@ import org.apache.plc4x.java.api.messages.PlcSubscriptionResponse; import org.apache.plc4x.java.api.types.PlcResponseCode; import org.apache.plc4x.java.api.value.*; -import org.apache.plc4x.java.spi.messages.DefaultPlcSubscriptionEvent; -import org.apache.plc4x.java.spi.messages.utils.ResponseItem; +import org.apache.plc4x.java.spi.drivers.messages.DefaultPlcSubscriptionEvent; +import org.apache.plc4x.java.spi.drivers.messages.items.PlcResponseItem; import org.apache.plc4x.java.spi.values.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -74,10 +74,9 @@ public void run() { DefaultPlcSubscriptionEvent internalEvent = (DefaultPlcSubscriptionEvent) plcSubscriptionEvent; final Point point = Point.measurement(configuration.getString("influx.measurement")) .time(plcSubscriptionEvent.getTimestamp().toEpochMilli(), WritePrecision.MS); - final Map> values = internalEvent.getValues(); - values.forEach((tagName, tagResponsePair) -> { - final PlcResponseCode responseCode = tagResponsePair.getCode(); - final PlcValue plcValue = tagResponsePair.getValue(); + for (String tagName : internalEvent.getTagNames()) { + final PlcResponseCode responseCode = internalEvent.getResponseCode(tagName); + final PlcValue plcValue = internalEvent.getPlcValue(tagName); if(responseCode == PlcResponseCode.OK) { PlcStruct structValue = (PlcStruct) plcValue; for (String key : structValue.getKeys()) { @@ -85,7 +84,7 @@ public void run() { registerTags(point, key, subValue); } } - }); + } writeApi.writePoint( configuration.getString("influx.bucket"), configuration.getString("influx.org"), point); }); diff --git a/plc4j/examples/hello-integration-iotdb/pom.xml b/plc4j/examples/hello-integration-iotdb/pom.xml index 84ebb13c3..bb07bff49 100644 --- a/plc4j/examples/hello-integration-iotdb/pom.xml +++ b/plc4j/examples/hello-integration-iotdb/pom.xml @@ -17,14 +17,12 @@ specific language governing permissions and limitations under the License. --> - - - 4.0.0 + org.apache.plc4x.examples plc4j-examples - 0.13.0-SNAPSHOT + 1.0.0-SNAPSHOT plc4j-examples-hello-integration-iotdb @@ -32,7 +30,7 @@ Application outputting PLC data into IoTDB. - 2024-02-16T14:53:02Z + 2026-09-20T15:22:10Z 1.3.2 org.apache.plc4x.java.examples.integration.iotdb.PlcLogger @@ -42,7 +40,7 @@ org.apache.plc4x plc4j-api - 0.13.0-SNAPSHOT + ${plc4x.version} diff --git a/plc4j/examples/hello-integration-iotdb/src/main/java/org/apache/plc4x/java/examples/integration/iotdb/PlcLogger.java b/plc4j/examples/hello-integration-iotdb/src/main/java/org/apache/plc4x/java/examples/integration/iotdb/PlcLogger.java index 206c0e688..8b5016af6 100644 --- a/plc4j/examples/hello-integration-iotdb/src/main/java/org/apache/plc4x/java/examples/integration/iotdb/PlcLogger.java +++ b/plc4j/examples/hello-integration-iotdb/src/main/java/org/apache/plc4x/java/examples/integration/iotdb/PlcLogger.java @@ -98,7 +98,7 @@ public static void main(String[] args) throws Exception { // Get a plc connection. - try (PlcConnection plcConnection = PlcDriverManager.getDefault().getConnectionManager().getConnection(options.getConnectionString())) { + try (PlcConnection plcConnection = PlcDriverManager.getDefault().getConnectionFactory().getConnection(options.getConnectionString())) { if(!plcConnection.getMetadata().isReadSupported()) { throw new UnsupportedOperationException("Driver doesn't support reading"); } diff --git a/plc4j/examples/hello-nats/pom.xml b/plc4j/examples/hello-nats/pom.xml index 4dbb3fbe6..6d7965467 100644 --- a/plc4j/examples/hello-nats/pom.xml +++ b/plc4j/examples/hello-nats/pom.xml @@ -17,14 +17,12 @@ specific language governing permissions and limitations under the License. --> - - - 4.0.0 + org.apache.plc4x.examples plc4j-examples - 0.13.0-SNAPSHOT + 1.0.0-SNAPSHOT plc4j-examples-hello-nats @@ -39,13 +37,13 @@ org.apache.plc4x plc4j-api - 0.13.0-SNAPSHOT + ${plc4x.version} io.nats jnats - 2.20.0 + 2.26.0 diff --git a/plc4j/examples/hello-nats/src/main/java/org/apache/plc4x/java/examples/hellonats/HelloNats.java b/plc4j/examples/hello-nats/src/main/java/org/apache/plc4x/java/examples/hellonats/HelloNats.java index 5daa0f4b5..9d4266523 100644 --- a/plc4j/examples/hello-nats/src/main/java/org/apache/plc4x/java/examples/hellonats/HelloNats.java +++ b/plc4j/examples/hello-nats/src/main/java/org/apache/plc4x/java/examples/hellonats/HelloNats.java @@ -67,7 +67,7 @@ public static void main(String[] args) throws Exception { final List tags = msg.getHeaders().get("tags"); // Establish a connection to the plc using the url provided as first argument - try (PlcConnection plcConnection = PlcDriverManager.getDefault().getConnectionManager().getConnection(connectionUrl)) { + try (PlcConnection plcConnection = PlcDriverManager.getDefault().getConnectionFactory().getConnection(connectionUrl)) { // Check if this connection support reading of data. if (!plcConnection.getMetadata().isReadSupported()) { diff --git a/plc4j/examples/hello-opm/pom.xml b/plc4j/examples/hello-opm/pom.xml index 6b5be3ed7..c17cc0d8f 100644 --- a/plc4j/examples/hello-opm/pom.xml +++ b/plc4j/examples/hello-opm/pom.xml @@ -17,14 +17,12 @@ specific language governing permissions and limitations under the License. --> - - - 4.0.0 + org.apache.plc4x.examples plc4j-examples - 0.13.0-SNAPSHOT + 1.0.0-SNAPSHOT plc4j-examples-hello-opm @@ -32,7 +30,7 @@ Hello World example of PLC4X using the OPM feature. - 2024-02-16T14:53:02Z + 2026-09-20T15:22:10Z org.apache.plc4x.java.examples.helloopm.HelloOpm @@ -40,17 +38,17 @@ org.apache.plc4x plc4j-api - 0.13.0-SNAPSHOT + ${plc4x.version} org.apache.plc4x - plc4j-opm - 0.13.0-SNAPSHOT + plc4j-tools-opm + ${plc4x.version} org.apache.plc4x - plc4j-connection-cache - 0.13.0-SNAPSHOT + plc4j-tools-connection-cache + ${plc4x.version} diff --git a/plc4j/examples/hello-opm/src/main/java/org/apache/plc4x/java/examples/helloopm/HelloOpm.java b/plc4j/examples/hello-opm/src/main/java/org/apache/plc4x/java/examples/helloopm/HelloOpm.java index 4c48bf4ed..42f0c0dd9 100644 --- a/plc4j/examples/hello-opm/src/main/java/org/apache/plc4x/java/examples/helloopm/HelloOpm.java +++ b/plc4j/examples/hello-opm/src/main/java/org/apache/plc4x/java/examples/helloopm/HelloOpm.java @@ -18,15 +18,16 @@ */ package org.apache.plc4x.java.examples.helloopm; +import org.apache.plc4x.java.DefaultPlcDriverManager; import org.apache.plc4x.java.opm.OPMException; import org.apache.plc4x.java.opm.PlcEntity; import org.apache.plc4x.java.opm.PlcEntityManager; import org.apache.plc4x.java.opm.PlcTag; -import org.apache.plc4x.java.utils.cache.CachedPlcConnectionManager; +import org.apache.plc4x.java.utils.cache.PlcConnectionCache; /** * This Example shows how to use OPM from plc4j via the @{@link PlcEntityManager}. - * A @{@link CachedPlcConnectionManager} is used to optimize the access and to allow for automatic reconnection. + * A @{@link PlcConnectionCache} is used to optimize the access and to allow for automatic reconnection. * * The {@link PlcEntityManager} is similar to JPAs EntityManager. * The "connected" Entity (shootCounter) can be kept and passed around and stays connected in the sense that all calls @@ -53,7 +54,9 @@ public static void main(String[] args) throws OPMException { } public HelloOpm() { - entityManager = new PlcEntityManager(CachedPlcConnectionManager.getBuilder().build()); + entityManager = new PlcEntityManager(PlcConnectionCache.getBuilder() + .withConnectionFactory(new DefaultPlcDriverManager()) + .build()); } /** diff --git a/plc4j/examples/hello-webservice/pom.xml b/plc4j/examples/hello-webservice/pom.xml index 9a6bf4886..dde7ae192 100644 --- a/plc4j/examples/hello-webservice/pom.xml +++ b/plc4j/examples/hello-webservice/pom.xml @@ -17,14 +17,12 @@ specific language governing permissions and limitations under the License. --> - - - 4.0.0 + org.apache.plc4x.examples plc4j-examples - 0.13.0-SNAPSHOT + 1.0.0-SNAPSHOT plc4j-examples-webservice @@ -32,7 +30,7 @@ Hello world application sending PLC4X data to a remote Webservice. - 2024-02-16T14:53:02Z + 2026-09-20T15:22:10Z org.apache.plc4x.java.examples.hellowebservice.HelloWebservice @@ -40,7 +38,7 @@ org.apache.plc4x plc4j-api - 0.13.0-SNAPSHOT + ${plc4x.version} @@ -50,7 +48,7 @@ com.google.code.gson gson - 2.11.0 + 2.14.0 org.apache.httpcomponents diff --git a/plc4j/examples/hello-webservice/src/main/java/org/apache/plc4x/java/examples/hellowebservice/HelloWebservice.java b/plc4j/examples/hello-webservice/src/main/java/org/apache/plc4x/java/examples/hellowebservice/HelloWebservice.java index 9b3f48598..31689665d 100644 --- a/plc4j/examples/hello-webservice/src/main/java/org/apache/plc4x/java/examples/hellowebservice/HelloWebservice.java +++ b/plc4j/examples/hello-webservice/src/main/java/org/apache/plc4x/java/examples/hellowebservice/HelloWebservice.java @@ -56,7 +56,7 @@ public HelloWebservice(CliOptions options) { public void run() throws Exception { // Establish a connection to the plc. - try (PlcConnection plcConnection = PlcDriverManager.getDefault().getConnectionManager().getConnection(options.getConnectionString())) { + try (PlcConnection plcConnection = PlcDriverManager.getDefault().getConnectionFactory().getConnection(options.getConnectionString())) { // Check if this connection support subscriptions. if (!plcConnection.getMetadata().isSubscribeSupported()) { diff --git a/plc4j/examples/hello-world-kotlin/pom.xml b/plc4j/examples/hello-world-kotlin/pom.xml index d2498b417..cbd63f780 100644 --- a/plc4j/examples/hello-world-kotlin/pom.xml +++ b/plc4j/examples/hello-world-kotlin/pom.xml @@ -17,22 +17,20 @@ specific language governing permissions and limitations under the License. --> - - 4.0.0 + org.apache.plc4x.examples plc4j-examples - 0.13.0-SNAPSHOT + 1.0.0-SNAPSHOT plc4j-examples-hello-world-kotlin PLC4J: Examples: Hello-World: Kotlin - 2024-02-16T14:53:02Z - 2024-02-16T14:32:21Z - 2.0.0 + 2026-09-20T15:22:10Z + 2.4.0 true org.apache.plc4x.java.examples.hellokotlin.ReadModbusPalKt @@ -46,12 +44,12 @@ org.apache.plc4x plc4j-api - 0.13.0-SNAPSHOT + ${plc4x.version} @@ -59,19 +57,19 @@ org.assertj assertj-core - 3.26.3 + 3.27.7 test org.junit.jupiter junit-jupiter-api - 5.10.3 + 6.1.1 test org.junit.jupiter junit-jupiter-engine - 5.10.3 + 6.1.1 test diff --git a/plc4j/examples/hello-world-kotlin/src/main/kotlin/org/apache/plc4x/java/examples/hellokotlin/ReadModbusPal.kt b/plc4j/examples/hello-world-kotlin/src/main/kotlin/org/apache/plc4x/java/examples/hellokotlin/ReadModbusPal.kt index 8644e86cf..4143f3097 100644 --- a/plc4j/examples/hello-world-kotlin/src/main/kotlin/org/apache/plc4x/java/examples/hellokotlin/ReadModbusPal.kt +++ b/plc4j/examples/hello-world-kotlin/src/main/kotlin/org/apache/plc4x/java/examples/hellokotlin/ReadModbusPal.kt @@ -34,9 +34,9 @@ fun main() { val readRequest = conn.readRequestBuilder() .addTagAddress("value-1", "coil:1") - .addTagAddress("value-2", "coil:3[4]") + .addTagAddress("value-2", "coil:3[0..3]") .addTagAddress("value-3", "holding-register:1") - .addTagAddress("value-4", "holding-register:3[4]") + .addTagAddress("value-4", "holding-register:3[0..3]") .build() val response = readRequest.execute().get(1, TimeUnit.MINUTES) diff --git a/plc4j/examples/hello-world-plc4x-discover-and-browse/pom.xml b/plc4j/examples/hello-world-plc4x-discover-and-browse/pom.xml index f44d4bf7d..4df1d55a5 100644 --- a/plc4j/examples/hello-world-plc4x-discover-and-browse/pom.xml +++ b/plc4j/examples/hello-world-plc4x-discover-and-browse/pom.xml @@ -17,14 +17,12 @@ specific language governing permissions and limitations under the License. --> - - - 4.0.0 + org.apache.plc4x.examples plc4j-examples - 0.13.0-SNAPSHOT + 1.0.0-SNAPSHOT plc4j-examples-hello-world-plc4x-discover-and-browse @@ -32,7 +30,7 @@ Hello world application for PLC4X using the discovery and browse-API. - 2024-02-16T14:53:02Z + 2026-09-20T15:22:10Z org.apache.plc4x.java.examples.helloplc4x.discoverandbrowse.HelloPlc4xDiscoverAndBrowse @@ -42,13 +40,13 @@ org.apache.plc4x plc4j-api - 0.13.0-SNAPSHOT + ${plc4x.version} org.apache.commons commons-lang3 - 3.15.0 + 3.20.0 diff --git a/plc4j/examples/hello-world-plc4x-discover-and-browse/src/main/java/org/apache/plc4x/java/examples/helloplc4x/discoverandbrowse/HelloPlc4xAdsBrowse.java b/plc4j/examples/hello-world-plc4x-discover-and-browse/src/main/java/org/apache/plc4x/java/examples/helloplc4x/discoverandbrowse/HelloPlc4xAdsBrowse.java index 52cdadde6..099bbaaee 100644 --- a/plc4j/examples/hello-world-plc4x-discover-and-browse/src/main/java/org/apache/plc4x/java/examples/helloplc4x/discoverandbrowse/HelloPlc4xAdsBrowse.java +++ b/plc4j/examples/hello-world-plc4x-discover-and-browse/src/main/java/org/apache/plc4x/java/examples/helloplc4x/discoverandbrowse/HelloPlc4xAdsBrowse.java @@ -20,7 +20,7 @@ import org.apache.commons.lang3.StringUtils; import org.apache.plc4x.java.api.PlcConnection; -import org.apache.plc4x.java.api.PlcConnectionManager; +import org.apache.plc4x.java.api.PlcConnectionFactory; import org.apache.plc4x.java.api.PlcDriver; import org.apache.plc4x.java.api.PlcDriverManager; import org.apache.plc4x.java.api.messages.PlcBrowseItem; @@ -36,12 +36,12 @@ public class HelloPlc4xAdsBrowse { public static void main(String[] args) throws Exception { // Iterate over all installed drivers and execute their browse functionality (If they support it) PlcDriverManager driverManager = PlcDriverManager.getDefault(); - PlcConnectionManager connectionManager = driverManager.getConnectionManager(); + PlcConnectionFactory connectionFactory = driverManager.getConnectionFactory(); PlcDriver driver = driverManager.getDriver("ads"); PlcDiscoveryRequest discoveryRequest = driver.discoveryRequestBuilder().build(); discoveryRequest.executeWithHandler(discoveryItem -> { logger.info(" - Found device with connection-url {}", discoveryItem.getConnectionUrl()); - try (PlcConnection connection = connectionManager.getConnection(discoveryItem.getConnectionUrl())) { + try (PlcConnection connection = connectionFactory.getConnection(discoveryItem.getConnectionUrl())) { if (connection.getMetadata().isBrowseSupported()) { PlcBrowseRequest browseRequest = connection.browseRequestBuilder().addQuery("all", "*").build(); browseRequest.execute().whenComplete((browseResponse, throwable) -> { diff --git a/plc4j/examples/hello-world-plc4x-discover-and-browse/src/main/java/org/apache/plc4x/java/examples/helloplc4x/discoverandbrowse/HelloPlc4xDiscoverAndBrowse.java b/plc4j/examples/hello-world-plc4x-discover-and-browse/src/main/java/org/apache/plc4x/java/examples/helloplc4x/discoverandbrowse/HelloPlc4xDiscoverAndBrowse.java index b30a3d142..61229b88e 100644 --- a/plc4j/examples/hello-world-plc4x-discover-and-browse/src/main/java/org/apache/plc4x/java/examples/helloplc4x/discoverandbrowse/HelloPlc4xDiscoverAndBrowse.java +++ b/plc4j/examples/hello-world-plc4x-discover-and-browse/src/main/java/org/apache/plc4x/java/examples/helloplc4x/discoverandbrowse/HelloPlc4xDiscoverAndBrowse.java @@ -20,7 +20,7 @@ import org.apache.commons.lang3.StringUtils; import org.apache.plc4x.java.api.PlcConnection; -import org.apache.plc4x.java.api.PlcConnectionManager; +import org.apache.plc4x.java.api.PlcConnectionFactory; import org.apache.plc4x.java.api.PlcDriver; import org.apache.plc4x.java.api.PlcDriverManager; import org.apache.plc4x.java.api.messages.PlcBrowseItem; @@ -36,7 +36,7 @@ public class HelloPlc4xDiscoverAndBrowse { public static void main(String[] args) throws Exception { // Iterate over all installed drivers and execute their browse functionality (If they support it) PlcDriverManager driverManager = PlcDriverManager.getDefault(); - PlcConnectionManager connectionManager = driverManager.getConnectionManager(); + PlcConnectionFactory connectionFactory = driverManager.getConnectionFactory(); for (String protocolCode : driverManager.getProtocolCodes()) { PlcDriver driver = driverManager.getDriver(protocolCode); if (driver.getMetadata().isDiscoverySupported()) { @@ -46,7 +46,7 @@ public static void main(String[] args) throws Exception { discoveryRequest.executeWithHandler(discoveryItem -> { logger.info(" - Found device with connection-url {}", discoveryItem.getConnectionUrl()); - try (PlcConnection connection = connectionManager.getConnection(discoveryItem.getConnectionUrl())) { + try (PlcConnection connection = connectionFactory.getConnection(discoveryItem.getConnectionUrl())) { if (connection.getMetadata().isBrowseSupported()) { PlcBrowseRequest browseRequest = connection.browseRequestBuilder().build(); browseRequest.execute().whenComplete((browseResponse, throwable) -> { diff --git a/plc4j/examples/hello-world-plc4x-read/pom.xml b/plc4j/examples/hello-world-plc4x-read/pom.xml index 7721843c8..a402a7c7a 100644 --- a/plc4j/examples/hello-world-plc4x-read/pom.xml +++ b/plc4j/examples/hello-world-plc4x-read/pom.xml @@ -17,14 +17,12 @@ specific language governing permissions and limitations under the License. --> - - - 4.0.0 + org.apache.plc4x.examples plc4j-examples - 0.13.0-SNAPSHOT + 1.0.0-SNAPSHOT plc4j-examples-hello-world-plc4x-read @@ -32,7 +30,7 @@ Hello world application for PLC4X using the read-API. - 2024-02-16T14:53:02Z + 2026-09-20T15:22:10Z org.apache.plc4x.java.examples.helloplc4x.read.HelloPlc4xRead @@ -40,7 +38,7 @@ org.apache.plc4x plc4j-api - 0.13.0-SNAPSHOT + ${plc4x.version} diff --git a/plc4j/examples/hello-world-plc4x-read/src/main/java/org/apache/plc4x/java/examples/helloplc4x/read/HelloPlc4xRead.java b/plc4j/examples/hello-world-plc4x-read/src/main/java/org/apache/plc4x/java/examples/helloplc4x/read/HelloPlc4xRead.java index e9d58db9d..443317166 100644 --- a/plc4j/examples/hello-world-plc4x-read/src/main/java/org/apache/plc4x/java/examples/helloplc4x/read/HelloPlc4xRead.java +++ b/plc4j/examples/hello-world-plc4x-read/src/main/java/org/apache/plc4x/java/examples/helloplc4x/read/HelloPlc4xRead.java @@ -44,7 +44,7 @@ public static void main(String[] args) throws Exception { } // Establish a connection to the plc using the url provided as first argument - try (PlcConnection plcConnection = PlcDriverManager.getDefault().getConnectionManager().getConnection(options.getConnectionString())) { + try (PlcConnection plcConnection = PlcDriverManager.getDefault().getConnectionFactory().getConnection(options.getConnectionString())) { // Check if this connection support reading of data. if (!plcConnection.getMetadata().isReadSupported()) { diff --git a/plc4j/examples/hello-world-plc4x-subscribe/pom.xml b/plc4j/examples/hello-world-plc4x-subscribe/pom.xml index 850b2ef08..813783c20 100644 --- a/plc4j/examples/hello-world-plc4x-subscribe/pom.xml +++ b/plc4j/examples/hello-world-plc4x-subscribe/pom.xml @@ -17,14 +17,12 @@ specific language governing permissions and limitations under the License. --> - - - 4.0.0 + org.apache.plc4x.examples plc4j-examples - 0.13.0-SNAPSHOT + 1.0.0-SNAPSHOT plc4j-examples-hello-world-plc4x-subscribe @@ -32,7 +30,7 @@ Hello world application for PLC4X using the subscription-API. - 2024-02-16T14:53:02Z + 2026-09-20T15:22:10Z org.apache.plc4x.java.examples.helloplc4x.subscribe.HelloPlc4xSubscription @@ -40,7 +38,7 @@ org.apache.plc4x plc4j-api - 0.13.0-SNAPSHOT + ${plc4x.version} diff --git a/plc4j/examples/hello-world-plc4x-subscribe/src/main/java/org/apache/plc4x/java/examples/helloplc4x/subscribe/HelloPlc4xSubscription.java b/plc4j/examples/hello-world-plc4x-subscribe/src/main/java/org/apache/plc4x/java/examples/helloplc4x/subscribe/HelloPlc4xSubscription.java index 933e4b94d..b4de9b336 100644 --- a/plc4j/examples/hello-world-plc4x-subscribe/src/main/java/org/apache/plc4x/java/examples/helloplc4x/subscribe/HelloPlc4xSubscription.java +++ b/plc4j/examples/hello-world-plc4x-subscribe/src/main/java/org/apache/plc4x/java/examples/helloplc4x/subscribe/HelloPlc4xSubscription.java @@ -43,7 +43,7 @@ public HelloPlc4xSubscription(CliOptions options) { public void run() throws Exception { // Establish a connection to the plc. - try (PlcConnection plcConnection = PlcDriverManager.getDefault().getConnectionManager().getConnection(options.getConnectionString())) { + try (PlcConnection plcConnection = PlcDriverManager.getDefault().getConnectionFactory().getConnection(options.getConnectionString())) { // Check if this connection support subscriptions. if (!plcConnection.getMetadata().isSubscribeSupported()) { diff --git a/plc4j/examples/hello-world-plc4x-write/pom.xml b/plc4j/examples/hello-world-plc4x-write/pom.xml index 8fa769aad..cbab18cf7 100644 --- a/plc4j/examples/hello-world-plc4x-write/pom.xml +++ b/plc4j/examples/hello-world-plc4x-write/pom.xml @@ -17,14 +17,12 @@ specific language governing permissions and limitations under the License. --> - - - 4.0.0 + org.apache.plc4x.examples plc4j-examples - 0.13.0-SNAPSHOT + 1.0.0-SNAPSHOT plc4j-examples-hello-world-plc4x-write @@ -32,7 +30,7 @@ Hello world application for PLC4X using the write-API. - 2024-02-16T14:53:02Z + 2026-09-20T15:22:10Z org.apache.plc4x.java.examples.helloplc4x.write.HelloPlc4xWrite @@ -40,7 +38,7 @@ org.apache.plc4x plc4j-api - 0.13.0-SNAPSHOT + ${plc4x.version} diff --git a/plc4j/examples/hello-world-plc4x-write/src/main/java/org/apache/plc4x/java/examples/helloplc4x/write/HelloPlc4xWrite.java b/plc4j/examples/hello-world-plc4x-write/src/main/java/org/apache/plc4x/java/examples/helloplc4x/write/HelloPlc4xWrite.java index 75cf71637..ba47a62ff 100644 --- a/plc4j/examples/hello-world-plc4x-write/src/main/java/org/apache/plc4x/java/examples/helloplc4x/write/HelloPlc4xWrite.java +++ b/plc4j/examples/hello-world-plc4x-write/src/main/java/org/apache/plc4x/java/examples/helloplc4x/write/HelloPlc4xWrite.java @@ -36,7 +36,7 @@ public HelloPlc4xWrite(CliOptions options) { public void run() throws Exception { // Establish a connection to the plc. - try (PlcConnection plcConnection = PlcDriverManager.getDefault().getConnectionManager().getConnection(options.getConnectionString())) { + try (PlcConnection plcConnection = PlcDriverManager.getDefault().getConnectionFactory().getConnection(options.getConnectionString())) { // Check if this connection support subscriptions. if (!plcConnection.getMetadata().isWriteSupported()) { diff --git a/plc4j/examples/plc4j-s7event/pom.xml b/plc4j/examples/plc4j-s7event/pom.xml index 5d3c62324..cc4ff67f0 100644 --- a/plc4j/examples/plc4j-s7event/pom.xml +++ b/plc4j/examples/plc4j-s7event/pom.xml @@ -17,14 +17,12 @@ specific language governing permissions and limitations under the License. --> - - - 4.0.0 + org.apache.plc4x.examples plc4j-examples - 0.13.0-SNAPSHOT + 1.0.0-SNAPSHOT plc4j-examples-s7event @@ -35,43 +33,43 @@ - 2024-02-16T14:53:02Z + 2025-08-06T15:45:09Z org.apache.plc4x plc4j-api - 0.13.0-SNAPSHOT + ${plc4x.version} org.apache.plc4x plc4j-driver-s7 - 0.13.0-SNAPSHOT + ${plc4x.version} commons-codec commons-codec - 1.17.1 + 1.22.0 org.apache.commons commons-lang3 - 3.15.0 + 3.20.0 io.netty netty-buffer - 4.1.112.Final + 4.1.119.Final io.netty netty-common - 4.1.112.Final + 4.2.15.Final diff --git a/plc4j/examples/plc4j-s7event/src/main/java/org/apache/plc4x/examples/plc4j/s7event/PlcCycSubscriptionS7400H.java b/plc4j/examples/plc4j-s7event/src/main/java/org/apache/plc4x/examples/plc4j/s7event/PlcCycSubscriptionS7400H.java index 6b2b946f7..78baffbc6 100644 --- a/plc4j/examples/plc4j-s7event/src/main/java/org/apache/plc4x/examples/plc4j/s7event/PlcCycSubscriptionS7400H.java +++ b/plc4j/examples/plc4j-s7event/src/main/java/org/apache/plc4x/examples/plc4j/s7event/PlcCycSubscriptionS7400H.java @@ -24,6 +24,7 @@ import org.apache.plc4x.java.api.messages.PlcSubscriptionRequest; import org.apache.plc4x.java.api.messages.PlcUnsubscriptionRequest; import org.apache.plc4x.java.api.messages.PlcSubscriptionResponse; +import org.apache.plc4x.java.api.model.PlcConnectionStateChangedEvent; import org.apache.plc4x.java.s7.events.S7CyclicEvent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -87,18 +88,18 @@ public void run (String[] args) throws Exception { logger.info("*****************************************************"); logger.info("* 1. Once the connection is executed, it must "); logger.info("* suscrict the data contained in the address."); - logger.info("* URL to:s7://10.10.1.80/10.10.1.81?remote-rack=0&"); - logger.info(" remote-slot=3&remote-rack2=0&remote-slot=4&"); - logger.info(" controller-type=S7_400&read-timeout=8&"); - logger.info(" ping=true&ping-time=2&retry-time=3"); + logger.info("* URL to:s7://10.10.1.80/10.10.1.81?cotp.remote-rack=0&"); + logger.info(" cotp.remote-slot=3&"); + logger.info(" controller-type=S7_400&read-timeout-ms=8000&"); + logger.info(" ha-heartbeat-interval-ms=2000"); logger.info("* Press [ENTER]"); logger.info("*****************************************************"); System.in.read(); - OpenConnection("s7://10.10.1.80/10.10.1.81?remote-rack=0&" - + "remote-slot=3&remote-rack2=0&remote-slot=4&" - + "controller-type=S7_400&read-timeout=8&" - + "ping=true&ping-time=2&retry-time=3"); //(01) + OpenConnection("s7://10.10.1.80/10.10.1.81?cotp.remote-rack=0&" + + "cotp.remote-slot=3&" + + "controller-type=S7_400&read-timeout-ms=8000&" + + "ha-heartbeat-interval-ms=2000"); //(01) logger.info("*****************************************************"); logger.info("* 2. In this step subscriptions are launched."); @@ -209,32 +210,34 @@ public void MakeUnsubscription() throws Exception { ShutDown.set(true); } - /*************************************************************************** - * This method is called when the driver makes an internal TCP connection. - * The first connection of the driver does not generate this event. - * In the case of high availability systems, this signal should be used - * to restart subscriptions to events, alarms, etc. - ***************************************************************************/ @Override - public void connected() { - logger.info("*****************************************************"); - logger.info("*************** Plc is connected. *******************"); - logger.info("*****************************************************"); - isConnected.set(true); + public void onConnectionStateChanged(PlcConnectionStateChangedEvent event) { + switch (event.getChangeType()) { + /* + * This block is called when the driver makes an internal TCP connection. + * The first connection of the driver does not generate this event. + * In the case of high availability systems, this signal should be used + * to restart subscriptions to events, alarms, etc. + */ + case CONNECTED -> { + logger.info("*****************************************************"); + logger.info("*************** Plc is connected. *******************"); + logger.info("*****************************************************"); + isConnected.set(true); + } + /* + * This block is called when there is a physical disconnection of the driver + * Check the monitoring parameters given in the URL during connection. + */ + case DISCONNECTED -> { + logger.info("*****************************************************"); + logger.info("*************** Plc is disconnected. ****************"); + logger.info("*****************************************************"); + isConnected.set(false); + } + } } - /*************************************************************************** - * This method is called when there is a physical disconnection of the driver - * Check the monitoring parameters given in the URL during connection. - ***************************************************************************/ - @Override - public void disconnected() { - logger.info("*****************************************************"); - logger.info("*************** Plc is disconnected. ****************"); - logger.info("*****************************************************"); - isConnected.set(false); - } - /*************************************************************************** * This object encapsulates the three steps required for cyclic subscription. * Try to handle all possible exceptions that are generated. diff --git a/plc4j/examples/plc4j-s7event/src/main/java/org/apache/plc4x/examples/plc4j/s7event/PlcEventSubscriptionS7400H.java b/plc4j/examples/plc4j-s7event/src/main/java/org/apache/plc4x/examples/plc4j/s7event/PlcEventSubscriptionS7400H.java index e10a3acdd..71fe8b42e 100644 --- a/plc4j/examples/plc4j-s7event/src/main/java/org/apache/plc4x/examples/plc4j/s7event/PlcEventSubscriptionS7400H.java +++ b/plc4j/examples/plc4j-s7event/src/main/java/org/apache/plc4x/examples/plc4j/s7event/PlcEventSubscriptionS7400H.java @@ -44,7 +44,7 @@ public class PlcEventSubscriptionS7400H { */ public static void main(String[] args) throws Exception { - try (PlcConnection connection = new DefaultPlcDriverManager().getConnection("s7://10.10.1.80?remote-rack=0&remote-slot=3&controller-type=S7_400")) { + try (PlcConnection connection = new DefaultPlcDriverManager().getConnection("s7://10.10.1.80?cotp.remote-rack=0&cotp.remote-slot=3&controller-type=S7_400")) { final PlcSubscriptionRequest.Builder subscription = connection.subscriptionRequestBuilder(); subscription.addEventTagAddress("myMODE", "MODE"); diff --git a/plc4j/examples/plc4j-s7event/src/main/java/org/apache/plc4x/examples/plc4j/s7event/PlcReadDataS7400H.java b/plc4j/examples/plc4j-s7event/src/main/java/org/apache/plc4x/examples/plc4j/s7event/PlcReadDataS7400H.java index 518868b52..2e286940a 100644 --- a/plc4j/examples/plc4j-s7event/src/main/java/org/apache/plc4x/examples/plc4j/s7event/PlcReadDataS7400H.java +++ b/plc4j/examples/plc4j-s7event/src/main/java/org/apache/plc4x/examples/plc4j/s7event/PlcReadDataS7400H.java @@ -30,6 +30,7 @@ import org.apache.plc4x.java.api.messages.PlcReadResponse; import org.apache.plc4x.java.api.messages.PlcWriteRequest; import org.apache.plc4x.java.api.messages.PlcWriteResponse; +import org.apache.plc4x.java.api.model.PlcConnectionStateChangedEvent; import org.apache.plc4x.java.api.types.PlcResponseCode; import org.apache.plc4x.java.s7.readwrite.protocol.S7HPlcConnection; import org.slf4j.Logger; @@ -77,18 +78,18 @@ public void run() throws IOException { logger.info("* . "); logger.info("*****************************************************"); - OpenConnection("s7://10.10.1.80/10.10.1.81?remote-rack=0&" - + "remote-slot=3&remote-rack2=0&remote-slot=4&" - + "controller-type=S7_400&read-timeout=8&" - + "ping=false&ping-time=2&retry-time=3"); //(01) + OpenConnection("s7://10.10.1.80/10.10.1.81?cotp.remote-rack=0&" + + "cotp.remote-slot=3&" + + "controller-type=S7_400&read-timeout-ms=8000&" + + "ha-heartbeat-interval-ms=2000"); //(01) logger.info("*****************************************************"); logger.info("* 1. Once the connection is executed, it must read"); logger.info("* the data contained in the address."); - logger.info("* URL to:s7://10.10.1.80/10.10.1.81?remote-rack=0&"); - logger.info(" remote-slot=3&remote-rack2=0&remote-slot=4&"); - logger.info(" controller-type=S7_400&read-timeout=8&"); - logger.info(" ping=true&ping-time=2&retry-time=3"); + logger.info("* URL to:s7://10.10.1.80/10.10.1.81?cotp.remote-rack=0&"); + logger.info(" cotp.remote-slot=3&"); + logger.info(" controller-type=S7_400&read-timeout-ms=8000&"); + logger.info(" ha-heartbeat-interval-ms=2000"); logger.info("* Press [ENTER]"); logger.info("*****************************************************"); System.in.read(); @@ -267,32 +268,34 @@ private void Write() { } catch (Exception ex) { //(07) logger.info("Write: " + ex.getMessage()); }; - } - /*************************************************************************** - * This method is called when the driver makes an internal TCP connection. - * The first connection of the driver does not generate this event. - * In the case of high availability systems, this signal should be used - * to restart subscriptions to events, alarms, etc. - ***************************************************************************/ - @Override - public void connected() { - logger.info("*****************************************************"); - logger.info("*************** Plc is connected. *******************"); - logger.info("*****************************************************"); - isConnected.set(true); } - /*************************************************************************** - * This method is called when there is a physical disconnection of the driver - * Check the monitoring parameters given in the URL during connection. - ***************************************************************************/ @Override - public void disconnected() { - logger.info("*****************************************************"); - logger.info("*************** Plc is disconnected. ****************"); - logger.info("*****************************************************"); - isConnected.set(false); - } - + public void onConnectionStateChanged(PlcConnectionStateChangedEvent event) { + switch (event.getChangeType()) { + /* + * This block is called when the driver makes an internal TCP connection. + * The first connection of the driver does not generate this event. + * In the case of high availability systems, this signal should be used + * to restart subscriptions to events, alarms, etc. + */ + case CONNECTED -> { + logger.info("*****************************************************"); + logger.info("*************** Plc is connected. *******************"); + logger.info("*****************************************************"); + isConnected.set(true); + } + /* + * This block is called when there is a physical disconnection of the driver + * Check the monitoring parameters given in the URL during connection. + */ + case DISCONNECTED -> { + logger.info("*****************************************************"); + logger.info("*************** Plc is disconnected. ****************"); + logger.info("*****************************************************"); + isConnected.set(false); + } + } + } } diff --git a/plc4j/examples/plc4j-s7event/src/main/java/org/apache/plc4x/examples/plc4j/s7event/PlcReadS71200.java b/plc4j/examples/plc4j-s7event/src/main/java/org/apache/plc4x/examples/plc4j/s7event/PlcReadS71200.java index 93ffb022a..9c9ee352a 100644 --- a/plc4j/examples/plc4j-s7event/src/main/java/org/apache/plc4x/examples/plc4j/s7event/PlcReadS71200.java +++ b/plc4j/examples/plc4j-s7event/src/main/java/org/apache/plc4x/examples/plc4j/s7event/PlcReadS71200.java @@ -28,6 +28,7 @@ import org.apache.plc4x.java.api.listener.ConnectionStateListener; import org.apache.plc4x.java.api.messages.PlcReadRequest; import org.apache.plc4x.java.api.messages.PlcReadResponse; +import org.apache.plc4x.java.api.model.PlcConnectionStateChangedEvent; import org.apache.plc4x.java.api.types.PlcResponseCode; import org.apache.plc4x.java.s7.readwrite.protocol.S7HPlcConnection; import org.slf4j.Logger; @@ -111,13 +112,13 @@ public void run() throws IOException { logger.info("* 4. Now we close the connection and open it using"); logger.info("* other parameters."); logger.info("* The new connection is given by"); - logger.info("* URL to: s7://10.10.1.46?read-timeout=6&ping=true&ping-time=2"); + logger.info("* URL to: s7://10.10.1.46?read-timeout-ms=6000"); logger.info("* Press [ENTER]"); logger.info("*****************************************************"); System.in.read(); CloseConnection(); //(04.1) - OpenConnection("s7://10.10.1.46?read-timeout=6&ping=true&ping-time=2"); //(04.2) + OpenConnection("s7://10.10.1.46?read-timeout-ms=6000"); //(04.2) logger.info("*****************************************************"); @@ -232,32 +233,34 @@ private void Read() { logger.info("Read: " + ex.getMessage()); }; - } - - /*************************************************************************** - * This method is called when the driver makes an internal TCP connection. - * The first connection of the driver does not generate this event. - * In the case of high availability systems, this signal should be used - * to restart subscriptions to events, alarms, etc. - ***************************************************************************/ - @Override - public void connected() { - logger.info("*****************************************************"); - logger.info("*************** Plc is connected. *******************"); - logger.info("*****************************************************"); - isConnected.set(true); } - /*************************************************************************** - * This method is called when there is a physical disconnection of the driver - * Check the monitoring parameters given in the URL during connection. - ***************************************************************************/ @Override - public void disconnected() { - logger.info("*****************************************************"); - logger.info("*************** Plc is disconnected. ****************"); - logger.info("*****************************************************"); - isConnected.set(false); + public void onConnectionStateChanged(PlcConnectionStateChangedEvent event) { + switch (event.getChangeType()) { + /* + * This block is called when the driver makes an internal TCP connection. + * The first connection of the driver does not generate this event. + * In the case of high availability systems, this signal should be used + * to restart subscriptions to events, alarms, etc. + */ + case CONNECTED -> { + logger.info("*****************************************************"); + logger.info("*************** Plc is connected. *******************"); + logger.info("*****************************************************"); + isConnected.set(true); + } + /* + * This block is called when there is a physical disconnection of the driver + * Check the monitoring parameters given in the URL during connection. + */ + case DISCONNECTED -> { + logger.info("*****************************************************"); + logger.info("*************** Plc is disconnected. ****************"); + logger.info("*****************************************************"); + isConnected.set(false); + } + } } - + } diff --git a/plc4j/examples/plc4j-s7event/src/main/java/org/apache/plc4x/examples/plc4j/s7event/PlcReadWriteStrings.java b/plc4j/examples/plc4j-s7event/src/main/java/org/apache/plc4x/examples/plc4j/s7event/PlcReadWriteStrings.java index ff586c71a..4d481f7d1 100644 --- a/plc4j/examples/plc4j-s7event/src/main/java/org/apache/plc4x/examples/plc4j/s7event/PlcReadWriteStrings.java +++ b/plc4j/examples/plc4j-s7event/src/main/java/org/apache/plc4x/examples/plc4j/s7event/PlcReadWriteStrings.java @@ -29,6 +29,7 @@ import org.apache.plc4x.java.api.messages.PlcReadResponse; import org.apache.plc4x.java.api.messages.PlcWriteRequest; import org.apache.plc4x.java.api.messages.PlcWriteResponse; +import org.apache.plc4x.java.api.model.PlcConnectionStateChangedEvent; import org.apache.plc4x.java.api.types.PlcResponseCode; import org.apache.plc4x.java.s7.readwrite.protocol.S7HPlcConnection; import org.slf4j.Logger; @@ -111,18 +112,18 @@ public void run() throws IOException { logger.info("* . "); logger.info("*****************************************************"); - OpenConnection("s7://10.10.1.80/10.10.1.81?remote-rack=0&" - + "remote-slot=3&remote-rack2=0&remote-slot=4&" - + "controller-type=S7_400&read-timeout=16&" - + "ping=true&ping-time=8&retry-time=3"); //(01) + OpenConnection("s7://10.10.1.80/10.10.1.81?cotp.remote-rack=0&" + + "cotp.remote-slot=3&" + + "controller-type=S7_400&read-timeout-ms=16000&" + + "ha-heartbeat-interval-ms=8000"); //(01) logger.info("*****************************************************"); logger.info("* 1. Once the connection is executed, it must read"); logger.info("* the data contained in the address."); - logger.info("* URL to:s7://10.10.1.80/10.10.1.81?remote-rack=0&"); - logger.info(" remote-slot=3&remote-rack2=0&remote-slot=4&"); - logger.info(" controller-type=S7_400&read-timeout=8&"); - logger.info(" ping=true&ping-time=2&retry-time=3"); + logger.info("* URL to:s7://10.10.1.80/10.10.1.81?cotp.remote-rack=0&"); + logger.info(" cotp.remote-slot=3&"); + logger.info(" controller-type=S7_400&read-timeout-ms=16000&"); + logger.info(" ha-heartbeat-interval-ms=8000"); logger.info("* Press [ENTER]"); logger.info("*****************************************************"); @@ -399,34 +400,34 @@ private void SafeRead(String str) { } catch (Exception ex) { //(07) logger.info("Read: " + ex.getMessage()); }; - } - - /*************************************************************************** - * This method is called when the driver makes an internal TCP connection. - * The first connection of the driver does not generate this event. - * In the case of high availability systems, this signal should be used - * to restart subscriptions to events, alarms, etc. - ***************************************************************************/ - @Override - public void connected() { - logger.info("*****************************************************"); - logger.info("*************** Plc is connected. *******************"); - logger.info("*****************************************************"); - isConnected.set(true); } - /*************************************************************************** - * This method is called when there is a physical disconnection of the driver - * Check the monitoring parameters given in the URL during connection. - ***************************************************************************/ @Override - public void disconnected() { - logger.info("*****************************************************"); - logger.info("*************** Plc is disconnected. ****************"); - logger.info("*****************************************************"); - isConnected.set(false); - } - - - + public void onConnectionStateChanged(PlcConnectionStateChangedEvent event) { + switch (event.getChangeType()) { + /* + * This block is called when the driver makes an internal TCP connection. + * The first connection of the driver does not generate this event. + * In the case of high availability systems, this signal should be used + * to restart subscriptions to events, alarms, etc. + */ + case CONNECTED -> { + logger.info("*****************************************************"); + logger.info("*************** Plc is connected. *******************"); + logger.info("*****************************************************"); + isConnected.set(true); + } + /* + * This block is called when there is a physical disconnection of the driver + * Check the monitoring parameters given in the URL during connection. + */ + case DISCONNECTED -> { + logger.info("*****************************************************"); + logger.info("*************** Plc is disconnected. ****************"); + logger.info("*****************************************************"); + isConnected.set(false); + } + } + } + } diff --git a/plc4j/examples/plc4j-s7event/src/main/java/org/apache/plc4x/examples/plc4j/s7event/PlcSzlRequestS7400H.java b/plc4j/examples/plc4j-s7event/src/main/java/org/apache/plc4x/examples/plc4j/s7event/PlcSzlRequestS7400H.java index be20c6e28..7ccc36da2 100644 --- a/plc4j/examples/plc4j-s7event/src/main/java/org/apache/plc4x/examples/plc4j/s7event/PlcSzlRequestS7400H.java +++ b/plc4j/examples/plc4j-s7event/src/main/java/org/apache/plc4x/examples/plc4j/s7event/PlcSzlRequestS7400H.java @@ -55,7 +55,7 @@ public static void main(String[] args) throws Exception { System.out.println("URL: https://cache.industry.siemens.com/dl/files/604/44240604/att_67003/v1/s7sfc_en-EN.pdf"); System.out.println("******************************************************************************************"); - try (PlcConnection connection = new DefaultPlcDriverManager().getConnection("s7://10.10.1.80?remote-rack=0&remote-slot=3&controller-type=S7_400")) { //(01) + try (PlcConnection connection = new DefaultPlcDriverManager().getConnection("s7://10.10.1.80?cotp.remote-rack=0&cotp.remote-slot=3&controller-type=S7_400")) { //(01) final PlcReadRequest.Builder readrequest = connection.readRequestBuilder(); //(02) diff --git a/plc4j/examples/poll-loop/pom.xml b/plc4j/examples/poll-loop/pom.xml index ab425afd3..ce053dc0d 100644 --- a/plc4j/examples/poll-loop/pom.xml +++ b/plc4j/examples/poll-loop/pom.xml @@ -17,23 +17,21 @@ specific language governing permissions and limitations under the License. --> - - - 4.0.0 + org.apache.plc4x.examples plc4j-examples - 0.13.0-SNAPSHOT + 1.0.0-SNAPSHOT plc4j-examples-poll-loop - 0.13.0-SNAPSHOT + PLC4J: Examples: Poll Loop Example Fetches data in a continuous loop - 2024-02-16T14:53:02Z + 2026-09-20T15:22:10Z org.apache.plc4x.java.examples.helloplc4x.HelloPlc4x @@ -41,7 +39,7 @@ org.apache.plc4x plc4j-api - 0.13.0-SNAPSHOT + ${plc4x.version} diff --git a/plc4j/examples/poll-loop/src/main/java/org/apache/plc4x/java/examples/pollloop/PollLoop.java b/plc4j/examples/poll-loop/src/main/java/org/apache/plc4x/java/examples/pollloop/PollLoop.java index 0184e2b71..30f6b6738 100644 --- a/plc4j/examples/poll-loop/src/main/java/org/apache/plc4x/java/examples/pollloop/PollLoop.java +++ b/plc4j/examples/poll-loop/src/main/java/org/apache/plc4x/java/examples/pollloop/PollLoop.java @@ -158,7 +158,7 @@ private void initPLC(String addressString, String plcType) { } try { - plcConnection = PlcDriverManager.getDefault().getConnectionManager().getConnection(connectionString); + plcConnection = PlcDriverManager.getDefault().getConnectionFactory().getConnection(connectionString); // in osgi/karaf uses this instead // switch (plcType) { // case PLC4JTYPE_SIEMENS: diff --git a/plc4j/examples/pom.xml b/plc4j/examples/pom.xml index 8d6f6be6b..79c3eef80 100644 --- a/plc4j/examples/pom.xml +++ b/plc4j/examples/pom.xml @@ -17,14 +17,12 @@ specific language governing permissions and limitations under the License. --> - - - 4.0.0 + - org.apache.plc4x + org.apache.plc4x.extras plc4j-extras - 0.13.0-SNAPSHOT + 1.0.0-SNAPSHOT org.apache.plc4x.examples @@ -35,32 +33,33 @@ Parent of all java based application example modules. - 2024-02-16T14:53:02Z + 2026-09-20T15:22:10Z override-this-property-in-application-pom - 1.8.0 - 1.5.6 - 2.0.13 + 1.11.0 + 1.5.38 + 2.0.18 - - hello-ads-telemetry - hello-cloud-azure - hello-cloud-google - hello-discovery - hello-connectivity-mqtt - hello-integration-iotdb - hello-nats - hello-opm - hello-webservice - hello-world-kotlin - hello-world-plc4x-discover-and-browse - hello-world-plc4x-read - hello-world-plc4x-subscribe - hello-world-plc4x-write - poll-loop - plc4j-s7event - + + hello-ads-telemetry + hello-cloud-azure + hello-cloud-google + hello-discovery + hello-connectivity-mqtt + hello-influx-data-collection + hello-integration-iotdb + hello-nats + hello-opm + hello-webservice + hello-world-kotlin + hello-world-plc4x-discover-and-browse + hello-world-plc4x-read + hello-world-plc4x-subscribe + hello-world-plc4x-write + poll-loop + + @@ -134,22 +133,10 @@ org.apache.plc4x plc4j-driver-all - 0.13.0-SNAPSHOT + ${plc4x.version} pom runtime - - - .above-or-equal-java-17 - - [17,) - - - hello-influx-data-collection - - - - \ No newline at end of file diff --git a/plc4j/integrations/apache-calcite/pom.xml b/plc4j/integrations/apache-calcite/pom.xml index 9cce2b67f..2adbc35b3 100644 --- a/plc4j/integrations/apache-calcite/pom.xml +++ b/plc4j/integrations/apache-calcite/pom.xml @@ -17,14 +17,12 @@ specific language governing permissions and limitations under the License. --> - - - 4.0.0 + - org.apache.plc4x + org.apache.plc4x.extras plc4j-integrations - 0.13.0-SNAPSHOT + 1.0.0-SNAPSHOT plc4j-apache-calcite @@ -32,8 +30,8 @@ PLC4J: Integrations: Apache Calcite - 2024-02-16T14:53:02Z - 1.37.0 + 2026-09-20T15:22:10Z + 1.42.0 @@ -50,24 +48,29 @@ org.apache.commons commons-lang3 - 3.15.0 + 3.20.0 org.apache.plc4x plc4j-api - 0.13.0-SNAPSHOT + ${plc4x.version} org.apache.plc4x - plc4j-scraper - 0.13.0-SNAPSHOT + plc4j-tools-event-pump + ${plc4x.version} + + + org.apache.plc4x + plc4j-tools-connection-cache + ${plc4x.version} org.apache.plc4x plc4j-driver-all - 0.13.0-SNAPSHOT + ${plc4x.version} pom runtime @@ -75,25 +78,25 @@ org.junit.jupiter junit-jupiter-api - 5.10.3 + 6.1.1 test org.assertj assertj-core - 3.26.3 + 3.27.7 test org.apache.commons commons-collections4 - 4.4 + 4.5.0 test - org.apache.plc4x - plc4j-connection-cache - 0.13.0-SNAPSHOT + ch.qos.logback + logback-classic + 1.5.38 test @@ -104,17 +107,17 @@ com.google.protobuf protobuf-java - 3.25.3 + 4.35.1 org.checkerframework checker-qual - 3.45.0 + 4.2.1 com.google.errorprone error_prone_annotations - 2.29.2 + 2.50.0 diff --git a/plc4j/integrations/apache-calcite/src/main/java/org/apache/plc4x/Plc4xBaseTable.java b/plc4j/integrations/apache-calcite/src/main/java/org/apache/plc4x/Plc4xBaseTable.java index 625e4d39c..445c185e6 100644 --- a/plc4j/integrations/apache-calcite/src/main/java/org/apache/plc4x/Plc4xBaseTable.java +++ b/plc4j/integrations/apache-calcite/src/main/java/org/apache/plc4x/Plc4xBaseTable.java @@ -29,7 +29,7 @@ import org.apache.calcite.schema.impl.AbstractTable; import org.apache.calcite.util.ImmutableBitSet; import org.apache.plc4x.java.api.exceptions.PlcRuntimeException; -import org.apache.plc4x.java.scraper.config.JobConfiguration; +import org.apache.plc4x.java.tools.eventpump.config.BatchConfiguration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -53,12 +53,12 @@ public abstract class Plc4xBaseTable extends AbstractTable { private static final Logger logger = LoggerFactory.getLogger(Plc4xBaseTable.class); private final BlockingQueue queue; - private final JobConfiguration conf; + private final BatchConfiguration conf; private final long tableCutoff; private Plc4xSchema.Record current; private final List names; - public Plc4xBaseTable(BlockingQueue queue, JobConfiguration conf, long tableCutoff) { + public Plc4xBaseTable(BlockingQueue queue, BatchConfiguration conf, long tableCutoff) { this.tableCutoff = tableCutoff; logger.info("Instantiating new PLC4X Table with configuration: {}", conf); this.queue = queue; @@ -107,7 +107,7 @@ public RelDataType getRowType(RelDataTypeFactory typeFactory) { } catch (ExecutionException | TimeoutException e) { throw new PlcRuntimeException("Unable to fetch first record and infer arguments!", e); } - logger.info("Inferring types for Table '{}' based on values: {}", conf.getName(), first.values); + logger.info("Inferring types for Table '{}' based on values: {}", conf.getId(), first.values); // Extract types List types = names.stream() .map(n -> { diff --git a/plc4j/integrations/apache-calcite/src/main/java/org/apache/plc4x/Plc4xSchema.java b/plc4j/integrations/apache-calcite/src/main/java/org/apache/plc4x/Plc4xSchema.java index 16222d4b1..5bce4c481 100644 --- a/plc4j/integrations/apache-calcite/src/main/java/org/apache/plc4x/Plc4xSchema.java +++ b/plc4j/integrations/apache-calcite/src/main/java/org/apache/plc4x/Plc4xSchema.java @@ -20,13 +20,15 @@ import org.apache.calcite.schema.Table; import org.apache.calcite.schema.impl.AbstractSchema; +import org.apache.plc4x.java.DefaultPlcDriverManager; import org.apache.plc4x.java.api.exceptions.PlcRuntimeException; -import org.apache.plc4x.java.scraper.ResultHandler; -import org.apache.plc4x.java.scraper.Scraper; -import org.apache.plc4x.java.scraper.ScraperImpl; -import org.apache.plc4x.java.scraper.config.JobConfiguration; -import org.apache.plc4x.java.scraper.config.ScraperConfiguration; -import org.apache.plc4x.java.scraper.exception.ScraperException; +import org.apache.plc4x.java.api.messages.PlcReadResponse; +import org.apache.plc4x.java.tools.eventpump.EventPump; +import org.apache.plc4x.java.tools.eventpump.TagBatch; +import org.apache.plc4x.java.tools.eventpump.config.BatchConfiguration; +import org.apache.plc4x.java.tools.eventpump.config.EventPumpConfiguration; +import org.apache.plc4x.java.tools.eventpump.config.EventPumpFactory; +import org.apache.plc4x.java.utils.cache.PlcConnectionCache; import java.time.Instant; import java.util.Map; @@ -36,32 +38,43 @@ public class Plc4xSchema extends AbstractSchema { - protected final ScraperConfiguration configuration; - protected final Scraper scraper; + protected final EventPumpConfiguration configuration; + protected final EventPump eventPump; protected final QueueHandler handler; protected final Map> queues; protected final Map tableMap; + /** batch id -> connection id, so a record can be attributed to the PLC it came from. */ + protected final Map connectionIds; - public Plc4xSchema(ScraperConfiguration configuration, long tableCutoff) throws ScraperException { + public Plc4xSchema(EventPumpConfiguration configuration, long tableCutoff) throws Exception { this.configuration = configuration; this.handler = new QueueHandler(); - this.scraper = new ScraperImpl(configuration, handler); - this.queues = configuration.getJobConfigurations().stream() + this.connectionIds = configuration.getBatches().stream() .collect(Collectors.toMap( - JobConfiguration::getName, + BatchConfiguration::getId, + BatchConfiguration::getConnectionId + )); + this.queues = configuration.getBatches().stream() + .collect(Collectors.toMap( + BatchConfiguration::getId, conf -> new ArrayBlockingQueue<>(1000) )); - // Create the tables - this.tableMap = configuration.getJobConfigurations().stream() + // Create the tables - one per batch + this.tableMap = configuration.getBatches().stream() .collect(Collectors.toMap( - JobConfiguration::getName, - conf -> defineTable(queues.get(conf.getName()), conf, tableCutoff) + BatchConfiguration::getId, + conf -> defineTable(queues.get(conf.getId()), conf, tableCutoff) )); - // Start the scraper - this.scraper.start(); + // Every batch reports to the same handler, which routes by batch id + this.eventPump = EventPumpFactory.create(configuration, + PlcConnectionCache.getBuilder() + .withConnectionFactory(new DefaultPlcDriverManager()) + .build(), + handler); + this.eventPump.startAll(); } - Table defineTable(BlockingQueue queue, JobConfiguration configuration, Long limit) { + Table defineTable(BlockingQueue queue, BatchConfiguration configuration, Long limit) { if (limit <= 0) { return new Plc4xStreamTable(queue, configuration); } else { @@ -88,13 +101,16 @@ public Record(Instant timestamp, String source, Map values) { } } - class QueueHandler implements ResultHandler { + class QueueHandler implements TagBatch.TagBatchListener { @Override - public void handle(String job, String alias, Map results) { + public void onTagsFetched(TagBatch batch, PlcReadResponse response) { + String batchId = batch.getBatchId(); + Map results = response.getTagNames().stream() + .collect(Collectors.toMap(name -> name, response::getObject)); try { - Record record = new Record(Instant.now(), alias, results); - queues.get(job).put(record); + Record record = new Record(Instant.now(), connectionIds.get(batchId), results); + queues.get(batchId).put(record); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new PlcRuntimeException("Handling got interrupted", e); diff --git a/plc4j/integrations/apache-calcite/src/main/java/org/apache/plc4x/Plc4xSchemaFactory.java b/plc4j/integrations/apache-calcite/src/main/java/org/apache/plc4x/Plc4xSchemaFactory.java index b28761072..92e4d2e56 100644 --- a/plc4j/integrations/apache-calcite/src/main/java/org/apache/plc4x/Plc4xSchemaFactory.java +++ b/plc4j/integrations/apache-calcite/src/main/java/org/apache/plc4x/Plc4xSchemaFactory.java @@ -22,12 +22,11 @@ import org.apache.calcite.schema.SchemaFactory; import org.apache.calcite.schema.SchemaPlus; import org.apache.commons.lang3.Validate; -import org.apache.plc4x.java.scraper.config.ScraperConfiguration; -import org.apache.plc4x.java.scraper.config.triggeredscraper.ScraperConfigurationTriggeredImpl; -import org.apache.plc4x.java.scraper.exception.ScraperException; +import org.apache.plc4x.java.tools.eventpump.config.EventPumpConfiguration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.File; import java.io.IOException; import java.util.Map; @@ -40,9 +39,17 @@ public Schema create(SchemaPlus parentSchema, String name, Map o Object config = operand.get("config"); Validate.notNull(config, "No configuration file given. Please specify operand 'config'...'"); // Load configuration from file - ScraperConfiguration configuration; + EventPumpConfiguration configuration; + String configPath = config.toString(); try { - configuration = ScraperConfiguration.fromFile(config.toString(), ScraperConfigurationTriggeredImpl.class); + File configFile = new File(configPath); + if (configPath.endsWith(".json")) { + configuration = EventPumpConfiguration.fromJson(configFile); + } else if (configPath.endsWith(".xml")) { + configuration = EventPumpConfiguration.fromXml(configFile); + } else { + configuration = EventPumpConfiguration.fromYaml(configFile); + } } catch (IOException e) { throw new IllegalArgumentException("Unable to load configuration file!", e); } @@ -59,8 +66,8 @@ public Schema create(SchemaPlus parentSchema, String name, Map o // Pass the configuration to the Schema try { return new Plc4xSchema(configuration, parsedLimit); - } catch (ScraperException e) { - LOGGER.warn("Could not evaluate Plc4xSchema",e); + } catch (Exception e) { + LOGGER.warn("Could not evaluate Plc4xSchema", e); //ToDo Exception, but interface does not accept ... null is fishy return null; } diff --git a/plc4j/integrations/apache-calcite/src/main/java/org/apache/plc4x/Plc4xStreamTable.java b/plc4j/integrations/apache-calcite/src/main/java/org/apache/plc4x/Plc4xStreamTable.java index 7c9668d91..840c8cac6 100644 --- a/plc4j/integrations/apache-calcite/src/main/java/org/apache/plc4x/Plc4xStreamTable.java +++ b/plc4j/integrations/apache-calcite/src/main/java/org/apache/plc4x/Plc4xStreamTable.java @@ -23,13 +23,13 @@ import org.apache.calcite.schema.ScannableTable; import org.apache.calcite.schema.StreamableTable; import org.apache.calcite.schema.Table; -import org.apache.plc4x.java.scraper.config.JobConfiguration; +import org.apache.plc4x.java.tools.eventpump.config.BatchConfiguration; import java.util.concurrent.BlockingQueue; public class Plc4xStreamTable extends Plc4xBaseTable implements ScannableTable, StreamableTable { - public Plc4xStreamTable(BlockingQueue queue, JobConfiguration conf) { + public Plc4xStreamTable(BlockingQueue queue, BatchConfiguration conf) { super(queue, conf, -1L); } diff --git a/plc4j/integrations/apache-calcite/src/main/java/org/apache/plc4x/Plc4xTable.java b/plc4j/integrations/apache-calcite/src/main/java/org/apache/plc4x/Plc4xTable.java index 9c479a03b..2b64b682e 100644 --- a/plc4j/integrations/apache-calcite/src/main/java/org/apache/plc4x/Plc4xTable.java +++ b/plc4j/integrations/apache-calcite/src/main/java/org/apache/plc4x/Plc4xTable.java @@ -21,13 +21,13 @@ import org.apache.calcite.DataContext; import org.apache.calcite.linq4j.Enumerable; import org.apache.calcite.schema.ScannableTable; -import org.apache.plc4x.java.scraper.config.JobConfiguration; +import org.apache.plc4x.java.tools.eventpump.config.BatchConfiguration; import java.util.concurrent.BlockingQueue; public class Plc4xTable extends Plc4xBaseTable implements ScannableTable { - public Plc4xTable(BlockingQueue queue, JobConfiguration conf, long tableCutoff) { + public Plc4xTable(BlockingQueue queue, BatchConfiguration conf, long tableCutoff) { super(queue, conf, tableCutoff); } diff --git a/plc4j/integrations/apache-calcite/src/test/java/org/apache/plc4x/DriverManagerTest.java b/plc4j/integrations/apache-calcite/src/test/java/org/apache/plc4x/DriverManagerTest.java index c9f93233f..8116acd03 100644 --- a/plc4j/integrations/apache-calcite/src/test/java/org/apache/plc4x/DriverManagerTest.java +++ b/plc4j/integrations/apache-calcite/src/test/java/org/apache/plc4x/DriverManagerTest.java @@ -20,12 +20,11 @@ import org.apache.calcite.jdbc.CalciteConnection; import org.apache.calcite.jdbc.Driver; -import org.apache.plc4x.java.scraper.config.ScraperConfiguration; -import org.apache.plc4x.java.scraper.config.ScraperConfigurationClassicImpl; -import org.apache.plc4x.java.scraper.exception.ScraperException; +import org.apache.plc4x.java.tools.eventpump.config.EventPumpConfiguration; import org.assertj.core.api.WithAssertions; import org.junit.jupiter.api.Test; +import java.io.File; import java.io.IOException; import java.sql.Connection; import java.sql.ResultSet; @@ -36,12 +35,12 @@ public class DriverManagerTest implements WithAssertions { @Test - void query() throws SQLException, IOException, ScraperException { + void query() throws Exception { Driver driver = new Driver(); Connection connection = driver.connect("jdbc:calcite:asdf;lex=MYSQL_ANSI", new Properties()); CalciteConnection calciteConnection = connection.unwrap(CalciteConnection.class); - calciteConnection.getRootSchema().add("plc4x", new Plc4xSchema(ScraperConfiguration.fromFile("src/test/resources/example.yml", ScraperConfigurationClassicImpl.class), 100)); + calciteConnection.getRootSchema().add("plc4x", new Plc4xSchema(EventPumpConfiguration.fromYaml(new File("src/test/resources/example.yml")), 100)); ResultSet rs = connection.prepareStatement("SELECT * FROM \"plc4x\".\"job1\"").executeQuery(); validateResult(rs); diff --git a/plc4j/integrations/apache-calcite/src/test/java/org/apache/plc4x/Plc4XBaseTableTest.java b/plc4j/integrations/apache-calcite/src/test/java/org/apache/plc4x/Plc4XBaseTableTest.java index 19c719a17..932e7ae00 100644 --- a/plc4j/integrations/apache-calcite/src/test/java/org/apache/plc4x/Plc4XBaseTableTest.java +++ b/plc4j/integrations/apache-calcite/src/test/java/org/apache/plc4x/Plc4XBaseTableTest.java @@ -19,7 +19,7 @@ package org.apache.plc4x; import org.apache.calcite.linq4j.Enumerator; -import org.apache.plc4x.java.scraper.config.JobConfigurationImpl; +import org.apache.plc4x.java.tools.eventpump.config.BatchConfiguration; import org.assertj.core.api.WithAssertions; import org.junit.jupiter.api.Test; @@ -33,12 +33,10 @@ class Plc4XBaseTableTest implements WithAssertions { @Test void testOnBlockingQueue() { ArrayBlockingQueue queue = new ArrayBlockingQueue<>(100); - Plc4xStreamTable table = new Plc4xStreamTable(queue, new JobConfigurationImpl( - "job1", - null, - 100, - Collections.emptyList(), - Collections.singletonMap("key", "address"))); + BatchConfiguration conf = new BatchConfiguration(); + conf.setId("job1"); + conf.setSimpleTags(Collections.singletonMap("key", "address")); + Plc4xStreamTable table = new Plc4xStreamTable(queue, conf); Map objects = Collections.singletonMap("key", "value"); queue.add(new Plc4xSchema.Record(Instant.now(), "", objects)); diff --git a/plc4j/integrations/apache-calcite/src/test/resources/example.yml b/plc4j/integrations/apache-calcite/src/test/resources/example.yml index d90dd3c12..b0809a178 100644 --- a/plc4j/integrations/apache-calcite/src/test/resources/example.yml +++ b/plc4j/integrations/apache-calcite/src/test/resources/example.yml @@ -17,16 +17,28 @@ # under the License. # ---------------------------------------------------------------------------- --- -sources: - test: simulated:test - test2: simulated:test2 +connections: + - id: test + url: simulated:test + - id: test2 + url: simulated:test2 -jobs: - - name: job1 - scrapeRate: 10 - sources: - - test - - test2 +# Every batch becomes one table, named after the batch id. +batches: + - id: job1 + connectionId: test tags: test: 'RANDOM/test:DINT' test2: 'RANDOM/test:STRING' + trigger: + type: timer + intervalMillis: 10 + + - id: job2 + connectionId: test2 + tags: + test: 'RANDOM/test:DINT' + test2: 'RANDOM/test:STRING' + trigger: + type: timer + intervalMillis: 10 diff --git a/plc4j/integrations/apache-kafka/README.md b/plc4j/integrations/apache-kafka/README.md index b8c5be770..13dbdbf57 100644 --- a/plc4j/integrations/apache-kafka/README.md +++ b/plc4j/integrations/apache-kafka/README.md @@ -203,7 +203,8 @@ If an error occurs when reading or writing PLC addresses a graceful backoff has bombarded with requests. However as the number of connectors for each PLC should be limited to reduce the load on the PLC, the graceful backoff shouldn't have a major impact. -For the source connector the PLC4X scraper logic is able to handle randomized polling rates on failures, this is buffered within the +For the source connector the PLC4X event-pump backs off exponentially on failures (1s, doubling up to 60s) and skips a poll +if the previous read is still running, this is buffered within the connector, the poll rate of the connector has no affect on the PLC poll rate. For the sink connector, if a write fails it is retried a configurable number of times with a timeout between each time. diff --git a/plc4j/integrations/apache-kafka/config/plc4x-source.properties b/plc4j/integrations/apache-kafka/config/plc4x-source.properties index 6c024cb77..13205b99b 100644 --- a/plc4j/integrations/apache-kafka/config/plc4x-source.properties +++ b/plc4j/integrations/apache-kafka/config/plc4x-source.properties @@ -32,7 +32,7 @@ sources.machineA.connectionString=simulated://127.0.0.1 #This value controls how often it returns when no messages are received. sources.machineA.pollReturnInterval=5000 -#There is an internal buffer between the PLC4X scraper and Kafka Connect. +#There is an internal buffer between the PLC4X event-pump and Kafka Connect. #This is the size of that buffer. sources.machineA.bufferSize=1000 @@ -45,7 +45,7 @@ sources.machineA.jobReferences.simulated-heartbeat.topic=simulated-heartbeat-top #A list of jobs specified in the following section. jobs=simulated-dashboard,simulated-heartbeat -#The poll rate for this job. the PLC4X scraper will request data every interval (ms). +#The poll rate for this job. The PLC4X event-pump will request data every interval (ms). jobs.simulated-dashboard.interval=1000 #A list of tags. Each tag is a map between an alias and a PLC4X address. diff --git a/plc4j/integrations/apache-kafka/pom.xml b/plc4j/integrations/apache-kafka/pom.xml index 4e9dc395f..0fe703cb0 100644 --- a/plc4j/integrations/apache-kafka/pom.xml +++ b/plc4j/integrations/apache-kafka/pom.xml @@ -17,14 +17,13 @@ specific language governing permissions and limitations under the License. --> - - 4.0.0 + + - org.apache.plc4x + org.apache.plc4x.extras plc4j-integrations - 0.13.0-SNAPSHOT + 1.0.0-SNAPSHOT plc4j-apache-kafka @@ -33,8 +32,8 @@ Integration module for integrating PLC4X into Apache Kafka (Kafka-Connect-Plugin) - 2024-02-16T14:53:02Z - 3.8.0 + 2026-09-20T15:22:10Z + 4.3.1 0.12.0 @@ -154,34 +153,34 @@ org.apache.plc4x plc4j-api - 0.13.0-SNAPSHOT + ${plc4x.version} org.apache.plc4x - plc4j-scraper - 0.13.0-SNAPSHOT + plc4j-tools-event-pump + ${plc4x.version} org.apache.plc4x - plc4j-connection-cache - 0.13.0-SNAPSHOT + plc4j-tools-connection-cache + ${plc4x.version} org.slf4j slf4j-api - 2.0.13 + 2.0.18 com.fasterxml.jackson.dataformat jackson-dataformat-yaml - 2.17.2 + 2.22.1 test org.apache.commons commons-collections4 - 4.4 + 4.5.0 test @@ -208,7 +207,7 @@ org.apache.plc4x plc4j-driver-all - 0.13.0-SNAPSHOT + ${plc4x.version} pom runtime @@ -216,7 +215,7 @@ org.junit.jupiter junit-jupiter-api - 5.10.3 + 6.1.1 test diff --git a/plc4j/integrations/apache-kafka/src/main/java/org/apache/plc4x/kafka/Plc4xSinkTask.java b/plc4j/integrations/apache-kafka/src/main/java/org/apache/plc4x/kafka/Plc4xSinkTask.java index 55da47c41..b179d2111 100644 --- a/plc4j/integrations/apache-kafka/src/main/java/org/apache/plc4x/kafka/Plc4xSinkTask.java +++ b/plc4j/integrations/apache-kafka/src/main/java/org/apache/plc4x/kafka/Plc4xSinkTask.java @@ -26,11 +26,12 @@ import org.apache.kafka.connect.sink.SinkRecord; import org.apache.kafka.connect.sink.SinkTask; import org.apache.kafka.connect.errors.RetriableException; +import org.apache.plc4x.java.DefaultPlcDriverManager; import org.apache.plc4x.java.api.PlcConnectionManager; import org.apache.plc4x.java.api.exceptions.PlcConnectionException; import org.apache.plc4x.java.api.PlcConnection; import org.apache.plc4x.java.api.messages.PlcWriteRequest; -import org.apache.plc4x.java.utils.cache.CachedPlcConnectionManager; +import org.apache.plc4x.java.utils.cache.PlcConnectionCache; import org.apache.plc4x.kafka.config.Constants; import org.apache.plc4x.kafka.util.VersionUtil; @@ -148,12 +149,25 @@ public void start(Map props) { } log.info("Creating Pooled PLC4x driver manager"); - connectionManager = CachedPlcConnectionManager.getBuilder().build(); + connectionManager = PlcConnectionCache.getBuilder() + .withConnectionFactory(new DefaultPlcDriverManager()) + .build(); } @Override public void stop() { synchronized (this) { + // The cache holds the connections it handed out, so it is this task's job to release + // them when the task goes away. The closed manager is kept: closing it again is a + // no-op, and a late put() then fails with a PlcConnectionException it already + // handles, rather than a NullPointerException. + if (connectionManager != null) { + try { + connectionManager.close(); + } catch (PlcConnectionException e) { + log.error("Error closing the connection manager", e); + } + } notifyAll(); // wake up thread waiting in awaitFetch } } diff --git a/plc4j/integrations/apache-kafka/src/main/java/org/apache/plc4x/kafka/Plc4xSourceConnector.java b/plc4j/integrations/apache-kafka/src/main/java/org/apache/plc4x/kafka/Plc4xSourceConnector.java index 552707566..86731a246 100644 --- a/plc4j/integrations/apache-kafka/src/main/java/org/apache/plc4x/kafka/Plc4xSourceConnector.java +++ b/plc4j/integrations/apache-kafka/src/main/java/org/apache/plc4x/kafka/Plc4xSourceConnector.java @@ -57,7 +57,7 @@ public Class taskClass() { @Override public List> taskConfigs(int maxTasks) { // Initially we planned to have the simple assumption that one task maps to one PLC connection. - // But we could easily say that one scraper instance maps to a task and one scraper task can + // But we could easily say that one event-pump instance maps to a task and one batch can // process multiple PLC connections. But I guess this would be an optimization as we have to // balance the load manually. if(sourceConfig.getJobs().size() > maxTasks) { @@ -66,8 +66,8 @@ public List> taskConfigs(int maxTasks) { return Collections.emptyList(); } - // For each configured source we'll start a dedicated scraper instance collecting - // all the scraper jobs enabled for this source. + // For each configured source we'll start a dedicated event-pump instance collecting + // all the jobs enabled for this source. List> configs = new LinkedList<>(); for (Source source : sourceConfig.getSources()) { // Build a list of job configurations only containing the ones referenced from diff --git a/plc4j/integrations/apache-kafka/src/main/java/org/apache/plc4x/kafka/Plc4xSourceTask.java b/plc4j/integrations/apache-kafka/src/main/java/org/apache/plc4x/kafka/Plc4xSourceTask.java index b8fa26c08..20cd19b1d 100644 --- a/plc4j/integrations/apache-kafka/src/main/java/org/apache/plc4x/kafka/Plc4xSourceTask.java +++ b/plc4j/integrations/apache-kafka/src/main/java/org/apache/plc4x/kafka/Plc4xSourceTask.java @@ -25,16 +25,15 @@ import org.apache.kafka.connect.errors.ConnectException; import org.apache.kafka.connect.source.SourceRecord; import org.apache.kafka.connect.source.SourceTask; +import org.apache.plc4x.java.DefaultPlcDriverManager; import org.apache.plc4x.java.api.PlcConnectionManager; +import org.apache.plc4x.java.api.exceptions.PlcConnectionException; import org.apache.plc4x.java.api.value.PlcValue; -import org.apache.plc4x.java.scraper.config.triggeredscraper.JobConfigurationTriggeredImplBuilder; -import org.apache.plc4x.java.scraper.config.triggeredscraper.ScraperConfigurationTriggeredImpl; -import org.apache.plc4x.java.scraper.config.triggeredscraper.ScraperConfigurationTriggeredImplBuilder; -import org.apache.plc4x.java.scraper.exception.ScraperException; -import org.apache.plc4x.java.scraper.triggeredscraper.TriggeredScraperImpl; -import org.apache.plc4x.java.scraper.triggeredscraper.triggerhandler.collector.TriggerCollector; -import org.apache.plc4x.java.scraper.triggeredscraper.triggerhandler.collector.TriggerCollectorImpl; -import org.apache.plc4x.java.utils.cache.CachedPlcConnectionManager; +import org.apache.plc4x.java.api.messages.PlcReadResponse; +import org.apache.plc4x.java.tools.eventpump.EventPump; +import org.apache.plc4x.java.tools.eventpump.TagBatch; +import org.apache.plc4x.java.tools.eventpump.triggers.TimerTrigger; +import org.apache.plc4x.java.utils.cache.PlcConnectionCache; import org.apache.plc4x.kafka.config.Constants; import org.apache.plc4x.kafka.util.VersionUtil; import org.slf4j.Logger; @@ -92,10 +91,11 @@ public class Plc4xSourceTask extends SourceTask { .field(Constants.JOB_NAME_FIELD, Schema.STRING_SCHEMA) .build(); - // Internal buffer into which all incoming scraper responses are written to. + // Internal buffer into which all incoming responses are written to. private ArrayBlockingQueue buffer; private Integer pollReturnInterval; - private TriggeredScraperImpl scraper; + private EventPump eventPump; + private PlcConnectionManager connectionManager; private final SecureRandom random = new SecureRandom(); @Override @@ -111,12 +111,14 @@ public void start(Map props) { pollReturnInterval = config.getInt(Constants.KAFKA_POLL_RETURN_CONFIG); Integer bufferSize = config.getInt(Constants.BUFFER_SIZE_CONFIG); - Map topics = new HashMap<>(); // Create a buffer with a capacity of BUFFER_SIZE_CONFIG elements which schedules access in a fair way. buffer = new ArrayBlockingQueue<>(bufferSize, true); - ScraperConfigurationTriggeredImplBuilder builder = new ScraperConfigurationTriggeredImplBuilder(); - builder.addSource(connectionName, plc4xConnectionString); + connectionManager = PlcConnectionCache.getBuilder() + .withConnectionFactory(new DefaultPlcDriverManager()) + .build(); + + eventPump = new EventPump(); List jobConfigs = config.getList(Constants.QUERIES_CONFIG); for (String jobConfig : jobConfigs) { @@ -128,11 +130,11 @@ public void start(Map props) { continue; } - String jobName = jobConfigSegments[0]; - String topic = jobConfigSegments[1]; - Integer rate = Integer.valueOf(jobConfigSegments[2]); - JobConfigurationTriggeredImplBuilder jobBuilder = builder.job( - jobName, String.format("(SCHEDULED,%s)", rate)).source(connectionName); + final String jobName = jobConfigSegments[0]; + final String topic = jobConfigSegments[1]; + final int rate = Integer.parseInt(jobConfigSegments[2]); + + Map tags = new LinkedHashMap<>(); for (int i = 3; i < jobConfigSegments.length; i++) { String[] tagSegments = jobConfigSegments[i].split("#"); if (tagSegments.length != 2) { @@ -141,106 +143,140 @@ public void start(Map props) { jobName, jobConfigSegments[i]); continue; } - String tagAlias = tagSegments[0]; - String tagAddress = tagSegments[1]; - jobBuilder.tag(tagAlias, tagAddress); - topics.put(jobName, topic); + tags.put(tagSegments[0], tagSegments[1]); + } + if (tags.isEmpty()) { + log.warn("Job configuration '{}' doesn't contain any valid tags ... skipping it.", jobName); + continue; } - jobBuilder.build(); - } - ScraperConfigurationTriggeredImpl scraperConfig = builder.build(); + // One batch per job: all tags of a job are read together, at the job's rate. + TagBatch batch = TagBatch.builder() + .withBatchId(jobName) + .withConnectionFactory(connectionManager) + .withConnectionString(plc4xConnectionString) + .addTagAddresses(tags) + .withTrigger(new TimerTrigger(rate, TimeUnit.MILLISECONDS)) + .withListener(new TagBatch.TagBatchListener() { + @Override + public void onTagsFetched(TagBatch tagBatch, PlcReadResponse response) { + handleResponse(tagBatch.getBatchId(), connectionName, topic, response); + } - try { - PlcConnectionManager connectionManager = CachedPlcConnectionManager.getBuilder().build(); - TriggerCollector triggerCollector = new TriggerCollectorImpl(connectionManager); - scraper = new TriggeredScraperImpl(scraperConfig, (jobName, sourceName, results) -> { - try { - Long timestamp = System.currentTimeMillis(); + @Override + public void onError(TagBatch tagBatch, Throwable error) { + log.error("Error reading tags for job '{}': {}", tagBatch.getBatchId(), error.getMessage()); + } - Map sourcePartition = new HashMap<>(); - sourcePartition.put("sourceName", sourceName); - sourcePartition.put("jobName", jobName); + @Override + public void onFetchSkipped(TagBatch tagBatch, long lastFetchDurationMs, long consecutiveSkips) { + log.warn("Job '{}' is configured to be read every {}ms, but the last read took {}ms " + + "({} consecutive reads skipped).", + tagBatch.getBatchId(), rate, lastFetchDurationMs, consecutiveSkips); + } + }) + .build(); + eventPump.addBatch(batch); + } - Map sourceOffset = Collections.singletonMap("offset", timestamp); + eventPump.startAll(); + } - String topic = topics.get(jobName); + /** + * Turns one response into a Kafka {@link SourceRecord} and adds it to the buffer that + * {@link #poll()} drains. + */ + private void handleResponse(String jobName, String sourceName, String topic, PlcReadResponse response) { + Map results = response.getTagNames().stream() + .collect(HashMap::new, (map, name) -> map.put(name, response.getObject(name)), HashMap::putAll); + try { + Long timestamp = System.currentTimeMillis(); - // Prepare the key structure. - Struct key = new Struct(KEY_SCHEMA) - .put(Constants.SOURCE_NAME_FIELD, sourceName) - .put(Constants.JOB_NAME_FIELD, jobName); + Map sourcePartition = new HashMap<>(); + sourcePartition.put("sourceName", sourceName); + sourcePartition.put("jobName", jobName); - // Build the Schema for the result struct. - SchemaBuilder tagSchemaBuilder = SchemaBuilder.struct() - .name("org.apache.plc4x.kafka.schema.Tag"); + Map sourceOffset = Collections.singletonMap("offset", timestamp); + // Prepare the key structure. + Struct key = new Struct(KEY_SCHEMA) + .put(Constants.SOURCE_NAME_FIELD, sourceName) + .put(Constants.JOB_NAME_FIELD, jobName); - for (Map.Entry result : results.entrySet()) { - // Get tag-name and -value from the results. - String tagName = result.getKey(); - Object tagValue = result.getValue(); + // Build the Schema for the result struct. + SchemaBuilder tagSchemaBuilder = SchemaBuilder.struct() + .name("org.apache.plc4x.kafka.schema.Tag"); - // Get the schema for the given value type. - Schema valueSchema = getSchema(tagValue); - // Add the schema description for the current tag. - tagSchemaBuilder.field(tagName, valueSchema); - } - Schema tagSchema = tagSchemaBuilder.build(); - - Schema recordSchema = SchemaBuilder.struct() - .name("org.apache.plc4x.kafka.schema.JobResult") - .doc("PLC Job result. This contains all of the received PLCValues as well as a received timestamp") - .field(Constants.TAGS_CONFIG, tagSchema) - .field(Constants.TIMESTAMP_CONFIG, Schema.INT64_SCHEMA) - .field(Constants.EXPIRES_CONFIG, Schema.OPTIONAL_INT64_SCHEMA) - .build(); - - // Build the struct itself. - Struct tagStruct = new Struct(tagSchema); - for (Map.Entry result : results.entrySet()) { - // Get tag-name and -value from the results. - String tagName = result.getKey(); - Object tagValue = result.getValue(); - - if (tagSchema.field(tagName).schema().type() == Schema.Type.ARRAY) { - tagStruct.put(tagName, ((List) tagValue).stream().map(p -> ((PlcValue) p).getObject()).collect(Collectors.toList())); - } else { - tagStruct.put(tagName, tagValue); - } - } + for (Map.Entry result : results.entrySet()) { + // Get tag-name and -value from the results. + String tagName = result.getKey(); + Object tagValue = result.getValue(); + + // Get the schema for the given value type. + Schema valueSchema = getSchema(tagValue); - Struct recordStruct = new Struct(recordSchema) - .put(Constants.TAGS_CONFIG, tagStruct) - .put(Constants.TIMESTAMP_CONFIG, timestamp); - - // Prepare the source-record element. - SourceRecord sourceRecord = new SourceRecord( - sourcePartition, sourceOffset, - topic, - KEY_SCHEMA, key, - recordSchema, recordStruct - ); - - // Add the new source-record to the buffer. - buffer.add(sourceRecord); - } catch (Exception e) { - log.error("Error while parsing returned values", e); + // Add the schema description for the current tag. + tagSchemaBuilder.field(tagName, valueSchema); + } + Schema tagSchema = tagSchemaBuilder.build(); + + Schema recordSchema = SchemaBuilder.struct() + .name("org.apache.plc4x.kafka.schema.JobResult") + .doc("PLC Job result. This contains all of the received PLCValues as well as a received timestamp") + .field(Constants.TAGS_CONFIG, tagSchema) + .field(Constants.TIMESTAMP_CONFIG, Schema.INT64_SCHEMA) + .field(Constants.EXPIRES_CONFIG, Schema.OPTIONAL_INT64_SCHEMA) + .build(); + + // Build the struct itself. + Struct tagStruct = new Struct(tagSchema); + for (Map.Entry result : results.entrySet()) { + // Get tag-name and -value from the results. + String tagName = result.getKey(); + Object tagValue = result.getValue(); + + if (tagSchema.field(tagName).schema().type() == Schema.Type.ARRAY) { + tagStruct.put(tagName, ((List) tagValue).stream().map(p -> ((PlcValue) p).getObject()).collect(Collectors.toList())); + } else { + tagStruct.put(tagName, tagValue); } - }, triggerCollector); - scraper.start(); - triggerCollector.start(); - } catch (ScraperException e) { - log.error("Error starting the scraper", e); + } + Struct recordStruct = new Struct(recordSchema) + .put(Constants.TAGS_CONFIG, tagStruct) + .put(Constants.TIMESTAMP_CONFIG, timestamp); + + // Prepare the source-record element. + SourceRecord sourceRecord = new SourceRecord( + sourcePartition, sourceOffset, + topic, + KEY_SCHEMA, key, + recordSchema, recordStruct + ); + + // Add the new source-record to the buffer. + buffer.add(sourceRecord); + } catch (Exception e) { + log.error("Error while parsing returned values", e); } } @Override public void stop() { synchronized (this) { - scraper.stop(); + if (eventPump != null) { + eventPump.close(); + } + // The pump is stopped first, so nothing asks for a connection while the cache + // that holds them is being torn down. + if (connectionManager != null) { + try { + connectionManager.close(); + } catch (PlcConnectionException e) { + log.error("Error closing the connection manager", e); + } + } notifyAll(); // wake up thread waiting in awaitFetch } } diff --git a/plc4j/integrations/apache-kafka/src/test/java/org/apache/plc4x/kafka/config/SinkTaskTest.java b/plc4j/integrations/apache-kafka/src/test/java/org/apache/plc4x/kafka/config/SinkTaskTest.java index 27b20a34d..5c187cfcb 100644 --- a/plc4j/integrations/apache-kafka/src/test/java/org/apache/plc4x/kafka/config/SinkTaskTest.java +++ b/plc4j/integrations/apache-kafka/src/test/java/org/apache/plc4x/kafka/config/SinkTaskTest.java @@ -22,6 +22,7 @@ import org.apache.kafka.common.record.TimestampType; +import org.apache.kafka.connect.errors.RetriableException; import org.apache.kafka.connect.sink.SinkRecord; import org.apache.kafka.connect.data.Schema; import org.apache.kafka.connect.data.Struct; @@ -79,7 +80,7 @@ public void parseConfig() throws Exception { assertEquals("machineSinkA", config.get(0).get(Constants.TOPIC_CONFIG)); assertEquals("5", config.get(0).get(Constants.RETRIES_CONFIG)); assertEquals("5000", config.get(0).get(Constants.TIMEOUT_CONFIG)); - assertEquals("running#RANDOM/Temporary:Boolean|conveyorEntry#RANDOM/Temporary:Boolean|load#RANDOM/Temporary:Boolean|unload#RANDOM/Temporary:Boolean|transferLeft#RANDOM/Temporary:Boolean|transferRight#RANDOM/Temporary:Boolean|conveyorLeft#RANDOM/Temporary:Boolean|conveyorRight#RANDOM/Temporary:Boolean|numLargeBoxes#STATE/Temporary:Integer|numSmallBoxes#RANDOM/Temporary:Integer", + assertEquals("running#RANDOM/Temporary:BOOL|conveyorEntry#RANDOM/Temporary:BOOL|load#RANDOM/Temporary:BOOL|unload#RANDOM/Temporary:BOOL|transferLeft#RANDOM/Temporary:BOOL|transferRight#RANDOM/Temporary:BOOL|conveyorLeft#RANDOM/Temporary:BOOL|conveyorRight#RANDOM/Temporary:BOOL|numLargeBoxes#STATE/Temporary:Integer|numSmallBoxes#RANDOM/Temporary:DINT", config.get(0).get(Constants.QUERIES_CONFIG)); } @@ -134,6 +135,28 @@ public void startTasks() throws Exception { } } + /** + * The task builds a PlcConnectionCache, which holds on to the connections it hands + * out - so stopping the task has to release them. Once it is closed, asking it for a + * connection fails, which is what makes the teardown observable from the outside. + */ + @Test + public void stopClosesTheConnectionManager() throws Exception { + log.info("-----------------SinkTaskTest.Stop----------------"); + Map taskConfig = sinkConnector.taskConfigs(1).get(0); + Plc4xSinkTask sinkTask = new Plc4xSinkTask(); + sinkTask.start(taskConfig); + + sinkTask.stop(); + // Closing an already closed manager does nothing, so stopping twice must not blow up. + assertDoesNotThrow(sinkTask::stop); + + List records = new ArrayList<>(); + records.add(new SinkRecord("machineSinkA", 1, null, null, null, null, 1)); + assertThrows(RetriableException.class, () -> sinkTask.put(records), + "the connection manager is closed, so no connection can be handed out any more"); + } + private static Map toStringMap(Properties properties) { Map map = new HashMap<>(); for (String stringPropertyName : properties.stringPropertyNames()) { diff --git a/plc4j/integrations/apache-kafka/src/test/java/org/apache/plc4x/kafka/config/SourceTaskTest.java b/plc4j/integrations/apache-kafka/src/test/java/org/apache/plc4x/kafka/config/SourceTaskTest.java index 2fdfe23b5..f4c830bc6 100644 --- a/plc4j/integrations/apache-kafka/src/test/java/org/apache/plc4x/kafka/config/SourceTaskTest.java +++ b/plc4j/integrations/apache-kafka/src/test/java/org/apache/plc4x/kafka/config/SourceTaskTest.java @@ -20,6 +20,7 @@ import static org.junit.jupiter.api.Assertions.*; +import org.apache.kafka.connect.source.SourceRecord; import org.apache.plc4x.kafka.Plc4xSourceConnector; import org.apache.plc4x.kafka.Plc4xSourceTask; @@ -69,7 +70,7 @@ public void parseConfig() throws Exception { assertEquals("simulated://127.0.0.1", config.get(0).get(Constants.CONNECTION_STRING_CONFIG)); assertEquals("1000", config.get(0).get(Constants.BUFFER_SIZE_CONFIG)); assertEquals("5000", config.get(0).get(Constants.KAFKA_POLL_RETURN_CONFIG)); - assertEquals("simulateddashboard|machineData|1000|running#RANDOM/Temporary:Boolean|conveyorEntry#RANDOM/Temporary:Boolean|load#RANDOM/Temporary:Boolean|unload#RANDOM/Temporary:Boolean|transferLeft#RANDOM/Temporary:Boolean|transferRight#RANDOM/Temporary:Boolean|conveyorLeft#RANDOM/Temporary:Boolean|conveyorRight#RANDOM/Temporary:Boolean|numLargeBoxes#RANDOM/Temporary:Integer|numSmallBoxes#RANDOM/Temporary:Integer[2],simulatedheartbeat|simulatedheartbeat|500|active#RANDOM/Temporary:Integer", config.get(0).get(Constants.QUERIES_CONFIG)); + assertEquals("simulateddashboard|machineData|1000|running#RANDOM/Temporary:BOOL|conveyorEntry#RANDOM/Temporary:BOOL|load#RANDOM/Temporary:BOOL|unload#RANDOM/Temporary:BOOL|transferLeft#RANDOM/Temporary:BOOL|transferRight#RANDOM/Temporary:BOOL|conveyorLeft#RANDOM/Temporary:BOOL|conveyorRight#RANDOM/Temporary:BOOL|numLargeBoxes#RANDOM/Temporary:DINT|numSmallBoxes#RANDOM/Temporary[0..1]:DINT,simulatedheartbeat|simulatedheartbeat|500|active#RANDOM/Temporary:DINT", config.get(0).get(Constants.QUERIES_CONFIG)); } @Test @@ -85,11 +86,37 @@ public void startTasks() throws Exception { sourceTask.start(taskConfig); } Thread.sleep(5000); - for (Plc4xSourceTask sourceTask : sourceList) { - assertNotNull(sourceTask.poll()); + try { + for (Plc4xSourceTask sourceTask : sourceList) { + List records = sourceTask.poll(); + assertNotNull(records); + // The jobs are configured to be read every 1000ms/500ms, so after 5s there + // has to be data in the buffer - an empty result means nothing was ever read. + assertFalse(records.isEmpty(), "expected the source task to have collected records"); + } + } finally { + for (Plc4xSourceTask sourceTask : sourceList) { + sourceTask.stop(); + } } } + /** + * The task builds a PlcConnectionCache, which holds on to the connections it hands + * out, so stopping the task has to release them - and stopping an already stopped task must + * stay harmless, because closing a closed manager does nothing. + */ + @Test + public void stopClosesTheConnectionManager() throws Exception { + log.info("-----------------SourceTaskTest.Stop----------------"); + Map taskConfig = sourceConnector.taskConfigs(2).get(0); + Plc4xSourceTask sourceTask = new Plc4xSourceTask(); + sourceTask.start(taskConfig); + + assertDoesNotThrow(sourceTask::stop); + assertDoesNotThrow(sourceTask::stop); + } + private static Map toStringMap(Properties properties) { Map map = new HashMap<>(); for (String stringPropertyName : properties.stringPropertyNames()) { diff --git a/plc4j/integrations/apache-kafka/src/test/java/org/apache/plc4x/kafka/properties/sink_task_no_error.properties b/plc4j/integrations/apache-kafka/src/test/java/org/apache/plc4x/kafka/properties/sink_task_no_error.properties index 68b31f37d..ea26c4f17 100644 --- a/plc4j/integrations/apache-kafka/src/test/java/org/apache/plc4x/kafka/properties/sink_task_no_error.properties +++ b/plc4j/integrations/apache-kafka/src/test/java/org/apache/plc4x/kafka/properties/sink_task_no_error.properties @@ -26,24 +26,24 @@ sinks.machineA.timeout=5000 sinks.machineA.retries=5 sinks.machineA.tags=running,conveyorEntry,load,unload,transferLeft,transferRight,conveyorLeft,conveyorRight,numLargeBoxes,numSmallBoxes -sinks.machineA.tags.running=RANDOM/Temporary:Boolean -sinks.machineA.tags.conveyorEntry=RANDOM/Temporary:Boolean -sinks.machineA.tags.load=RANDOM/Temporary:Boolean -sinks.machineA.tags.unload=RANDOM/Temporary:Boolean -sinks.machineA.tags.transferLeft=RANDOM/Temporary:Boolean -sinks.machineA.tags.transferRight=RANDOM/Temporary:Boolean -sinks.machineA.tags.conveyorLeft=RANDOM/Temporary:Boolean -sinks.machineA.tags.conveyorRight=RANDOM/Temporary:Boolean +sinks.machineA.tags.running=RANDOM/Temporary:BOOL +sinks.machineA.tags.conveyorEntry=RANDOM/Temporary:BOOL +sinks.machineA.tags.load=RANDOM/Temporary:BOOL +sinks.machineA.tags.unload=RANDOM/Temporary:BOOL +sinks.machineA.tags.transferLeft=RANDOM/Temporary:BOOL +sinks.machineA.tags.transferRight=RANDOM/Temporary:BOOL +sinks.machineA.tags.conveyorLeft=RANDOM/Temporary:BOOL +sinks.machineA.tags.conveyorRight=RANDOM/Temporary:BOOL sinks.machineA.tags.numLargeBoxes=STATE/Temporary:Integer -sinks.machineA.tags.numSmallBoxes=RANDOM/Temporary:Integer +sinks.machineA.tags.numSmallBoxes=RANDOM/Temporary:DINT sinks.machineB.connectionString=simulated://127.0.0.1 sinks.machineB.topic=machineSinkB sinks.machineB.timeout=5000 sinks.machineB.retries=5 sinks.machineB.tags=running,conveyorEntry -sinks.machineB.tags.running=RANDOM/Temporary:Boolean -sinks.machineB.tags.conveyorEntry=RANDOM/Temporary:Boolean +sinks.machineB.tags.running=RANDOM/Temporary:BOOL +sinks.machineB.tags.conveyorEntry=RANDOM/Temporary:BOOL bootstrap.servers=127.0.0.1:9092 key.converter=org.apache.kafka.connect.json.JsonConverter diff --git a/plc4j/integrations/apache-kafka/src/test/java/org/apache/plc4x/kafka/properties/source_task_no_error.properties b/plc4j/integrations/apache-kafka/src/test/java/org/apache/plc4x/kafka/properties/source_task_no_error.properties index adf250e1f..c047699ee 100644 --- a/plc4j/integrations/apache-kafka/src/test/java/org/apache/plc4x/kafka/properties/source_task_no_error.properties +++ b/plc4j/integrations/apache-kafka/src/test/java/org/apache/plc4x/kafka/properties/source_task_no_error.properties @@ -31,20 +31,20 @@ jobs=simulateddashboard,simulatedheartbeat jobs.simulateddashboard.interval=1000 jobs.simulateddashboard.tags=running,conveyorEntry,load,unload,transferLeft,transferRight,conveyorLeft,conveyorRight,numLargeBoxes,numSmallBoxes -jobs.simulateddashboard.tags.running=RANDOM/Temporary:Boolean -jobs.simulateddashboard.tags.conveyorEntry=RANDOM/Temporary:Boolean -jobs.simulateddashboard.tags.load=RANDOM/Temporary:Boolean -jobs.simulateddashboard.tags.unload=RANDOM/Temporary:Boolean -jobs.simulateddashboard.tags.transferLeft=RANDOM/Temporary:Boolean -jobs.simulateddashboard.tags.transferRight=RANDOM/Temporary:Boolean -jobs.simulateddashboard.tags.conveyorLeft=RANDOM/Temporary:Boolean -jobs.simulateddashboard.tags.conveyorRight=RANDOM/Temporary:Boolean -jobs.simulateddashboard.tags.numLargeBoxes=RANDOM/Temporary:Integer -jobs.simulateddashboard.tags.numSmallBoxes=RANDOM/Temporary:Integer[2] +jobs.simulateddashboard.tags.running=RANDOM/Temporary:BOOL +jobs.simulateddashboard.tags.conveyorEntry=RANDOM/Temporary:BOOL +jobs.simulateddashboard.tags.load=RANDOM/Temporary:BOOL +jobs.simulateddashboard.tags.unload=RANDOM/Temporary:BOOL +jobs.simulateddashboard.tags.transferLeft=RANDOM/Temporary:BOOL +jobs.simulateddashboard.tags.transferRight=RANDOM/Temporary:BOOL +jobs.simulateddashboard.tags.conveyorLeft=RANDOM/Temporary:BOOL +jobs.simulateddashboard.tags.conveyorRight=RANDOM/Temporary:BOOL +jobs.simulateddashboard.tags.numLargeBoxes=RANDOM/Temporary:DINT +jobs.simulateddashboard.tags.numSmallBoxes=RANDOM/Temporary[0..1]:DINT jobs.simulatedheartbeat.interval=500 jobs.simulatedheartbeat.tags=active -jobs.simulatedheartbeat.tags.active=RANDOM/Temporary:Integer +jobs.simulatedheartbeat.tags.active=RANDOM/Temporary:DINT bootstrap.servers=localhost:9092 #key.converter=org.apache.kafka.connect.json.JsonConverter diff --git a/plc4j/integrations/apache-nifi/.gitignore b/plc4j/integrations/apache-nifi/.gitignore new file mode 100644 index 000000000..8652d8810 --- /dev/null +++ b/plc4j/integrations/apache-nifi/.gitignore @@ -0,0 +1 @@ +**/target*/* \ No newline at end of file diff --git a/plc4j/integrations/apache-nifi/README.md b/plc4j/integrations/apache-nifi/README.md index 04aeed865..fd0843547 100644 --- a/plc4j/integrations/apache-nifi/README.md +++ b/plc4j/integrations/apache-nifi/README.md @@ -20,7 +20,7 @@ under the License. ## Common properties The following properties applies to all Plc4x Processors: -* Connection String: A constant connection string such as `s7://10.105.143.7:102?remote-rack=0&remote-slot=1&controller-type=S7_1200` or a valid Expression Language ([Expression Language NiFi documentation](https://nifi.apache.org/docs/nifi-docs/html/expression-language-guide.html)) such as `${plc4x.connection_string}`. +* Connection String: A constant connection string such as `s7://10.105.143.7:102?cotp.remote-rack=0&cotp.remote-slot=1&controller-type=S7_1200` or a valid Expression Language ([Expression Language NiFi documentation](https://nifi.apache.org/docs/nifi-docs/html/expression-language-guide.html)) such as `${plc4x.connection_string}`. * Timeout (miliseconds): Specifies the time in milliseconds for the connection to return a timeout. Is used to renew connections. Can be set with Expression Language. * Timestamp field name: It defines the name of the field that represents the time when the response from the Plc was received. It will be added to the attributes or to the record deppending on the processor used. * Address Access Strategy: defines how the processor obtains the PLC addresses. It can take 2 values: @@ -71,33 +71,33 @@ When reading from a PLC the response is used to create a mapping between Plc typ Table of data mapping between plc data and Avro types (as specified in [Avro specification](https://avro.apache.org/docs/1.11.1/specification/#primitive-types)). -| PLC type | Avro Type | -|----------:|-----------| -| PlcBOOL | boolean | -| PlcBYTE | bytes | -| PlcSINT | int | -| PlcINT | int | -| PlcLINT | long | -| PlcREAL | float | -| PlcLREAL | double | -| PlcCHAR | string | -| PlcDATE_AND_TIME | string | -| PlcDATE | string | -| PlcDINT | string | -| PlcDWORD | string | -| PlcLTIME | string | -| PlcLWORD | string | -| PlcNull | string | -| PlcSTRING | string | -| PlcTIME_OF_DAY | string | -| PlcTIME | string | -| PlcUDINT | string | -| PlcUINT | string | -| PlcULINT | string | -| PlcUSINT | string | -| PlcWCHAR | string | -| PlcWORD | string | -| ELSE | string | +| PLC type | Avro Type | +|-----------------:|-----------| +| PlcBOOL | boolean | +| PlcBYTE | bytes | +| PlcSINT | int | +| PlcINT | int | +| PlcLINT | long | +| PlcREAL | float | +| PlcLREAL | double | +| PlcCHAR | string | +| PlcDATE_AND_TIME | string | +| PlcDATE | string | +| PlcDINT | string | +| PlcDWORD | string | +| PlcLTIME | string | +| PlcLWORD | string | +| PlcNull | string | +| PlcSTRING | string | +| PlcTIME_OF_DAY | string | +| PlcTIME | string | +| PlcUDINT | string | +| PlcUINT | string | +| PlcULINT | string | +| PlcUSINT | string | +| PlcWCHAR | string | +| PlcWORD | string | +| ELSE | string | Also, it is important to keep in mind the Processor Scheduling Configuration. Using the parameter **Run Schedule** (for example to *1 sec*), the reading frequency can be set. Note that by default, this value is defined to 0 sec (as fast as possible). @@ -133,7 +133,7 @@ The Plc4xListenRecordProcessor can be configured using the common properties def An *example* for reading values from a S7-1200: -- *PLC connection String:* *s7://10.105.143.7:102?remote-rack=0&remote-slot=1&controller-type=S7_1200* +- *PLC connection String:* *s7://10.105.143.7:102?cotp.remote-rack=0&cotp.remote-slot=1&controller-type=S7_1200* - *Record Writer:* *PLC4x Embedded - AvroRecordSetWriter* - *Read timeout (miliseconds):* *10000* - *Timestamp field name:* *timestamp* diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-nar/bin/.gitignore b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-nar/bin/.gitignore similarity index 100% rename from plc4j/integrations/apache-nifi/nifi-plc4x-nar/bin/.gitignore rename to plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-nar/bin/.gitignore diff --git a/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-nar/pom.xml b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-nar/pom.xml new file mode 100644 index 000000000..f5c16d2e8 --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-nar/pom.xml @@ -0,0 +1,85 @@ + + + + + + org.apache.plc4x.extras + plc4j-apache-nifi-1 + 1.0.0-SNAPSHOT + + + plc4j-nifi-plc4x-nar + nar + + PLC4J: Integrations: Apache Nifi 1: NAR + + + + + org.apache.nifi + nifi-standard-services-api-nar + ${nifi.version} + nar + + + + + org.apache.plc4x.extras + plc4j-nifi-plc4x-processors + 1.0.0-SNAPSHOT + + + + org.apache.plc4x + plc4j-api + ${plc4x.version} + + + + + org.apache.plc4x + plc4j-driver-all + ${plc4x.version} + pom + + + + + + + + + org.apache.nifi + nifi-nar-maven-plugin + + + + org.codehaus.mojo + buildnumber-maven-plugin + + + unknown + + + + + + + \ No newline at end of file diff --git a/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/pom.xml b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/pom.xml new file mode 100644 index 000000000..cb8b80950 --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/pom.xml @@ -0,0 +1,176 @@ + + + + + + org.apache.plc4x.extras + plc4j-apache-nifi-1 + 1.0.0-SNAPSHOT + + + plc4j-nifi-plc4x-processors + + PLC4J: Integrations: Apache Nifi 1: Processors + + + 2026-09-20T15:22:10Z + + + + + org.apache.plc4x + plc4j-api + ${plc4x.version} + + + org.apache.plc4x + plc4j-tools-connection-cache + ${plc4x.version} + + + org.apache.plc4x + plc4j-spi-drivers + ${plc4x.version} + + + + + org.apache.nifi + nifi-api + + + org.apache.nifi + nifi-utils + + + org.apache.nifi + nifi-record + + + org.apache.nifi + nifi-record-serialization-service-api + + + org.apache.nifi + nifi-json-utils + + + + + com.fasterxml.jackson.core + jackson-core + + + com.fasterxml.jackson.core + jackson-databind + + + + + org.apache.nifi + nifi-record-serialization-services + test + + + javax.validation + validation-api + + + + + + org.apache.nifi + nifi-schema-registry-service-api + test + + + org.apache.nifi + nifi-mock + test + + + org.apache.nifi + nifi-mock-record-utils + test + + + + + org.apache.plc4x + plc4j-driver-modbus + ${plc4x.version} + test + + + org.apache.plc4x + plc4j-driver-opcua + ${plc4x.version} + test + + + org.apache.plc4x + plc4j-driver-s7 + ${plc4x.version} + test + + + org.apache.plc4x + plc4j-driver-simulated + ${plc4x.version} + test + + + org.apache.plc4x + plc4j-transports-pcap-replay + ${plc4x.version} + test + + + org.apache.plc4x + plc4j-transports-raw-socket + ${plc4x.version} + test + + + org.apache.plc4x + plc4j-transports-serial + ${plc4x.version} + test + + + org.apache.plc4x + plc4j-transports-tcp + ${plc4x.version} + test + + + org.apache.plc4x + plc4j-transports-udp + ${plc4x.version} + test + + + org.mockito + mockito-core + 5.23.0 + test + + + \ No newline at end of file diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/BasePlc4xProcessor.java b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/BasePlc4xProcessor.java similarity index 87% rename from plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/BasePlc4xProcessor.java rename to plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/BasePlc4xProcessor.java index b2b05635d..d87308e6a 100644 --- a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/BasePlc4xProcessor.java +++ b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/BasePlc4xProcessor.java @@ -19,7 +19,6 @@ package org.apache.plc4x.nifi; import java.io.OutputStream; -import java.time.Duration; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -28,6 +27,7 @@ import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; import org.apache.nifi.annotation.lifecycle.OnScheduled; @@ -43,20 +43,18 @@ import org.apache.nifi.processor.ProcessSession; import org.apache.nifi.processor.ProcessorInitializationContext; import org.apache.nifi.processor.Relationship; -import org.apache.nifi.processor.exception.ProcessException; import org.apache.nifi.processor.util.StandardValidators; import org.apache.nifi.serialization.record.RecordSchema; import org.apache.plc4x.java.DefaultPlcDriverManager; import org.apache.plc4x.java.api.PlcConnection; import org.apache.plc4x.java.api.PlcDriver; -import org.apache.plc4x.java.api.exceptions.PlcConnectionException; import org.apache.plc4x.java.api.messages.PlcReadRequest; import org.apache.plc4x.java.api.messages.PlcReadResponse; import org.apache.plc4x.java.api.messages.PlcWriteRequest; import org.apache.plc4x.java.api.messages.PlcWriteResponse; import org.apache.plc4x.java.api.model.PlcTag; import org.apache.plc4x.java.api.types.PlcResponseCode; -import org.apache.plc4x.java.utils.cache.CachedPlcConnectionManager; +import org.apache.plc4x.java.utils.cache.PlcConnectionCache; import org.apache.plc4x.nifi.address.AddressesAccessStrategy; import org.apache.plc4x.nifi.address.AddressesAccessUtils; import org.apache.plc4x.nifi.address.DynamicPropertyAccessStrategy; @@ -73,16 +71,17 @@ public abstract class BasePlc4xProcessor extends AbstractProcessor { protected final SchemaCache schemaCache = new SchemaCache(0); protected AddressesAccessStrategy addressAccessStrategy; - private CachedPlcConnectionManager connectionManager; + private PlcConnectionCache connectionCache; - protected CachedPlcConnectionManager getConnectionManager() { - return connectionManager; + protected PlcConnectionCache getConnectionCache() { + return connectionCache; } - protected void refreshConnectionManager() { - connectionManager = CachedPlcConnectionManager.getBuilder() - .withMaxLeaseTime(Duration.ofSeconds(1000L)) - .withMaxWaitTime(Duration.ofSeconds(500L)) + protected void refreshConnectionCache() { + connectionCache = PlcConnectionCache.getBuilder() + .withConnectionFactory(new DefaultPlcDriverManager()) + .withMaxLeaseTime(1000L, TimeUnit.SECONDS) + .withMaxWaitTime(500L, TimeUnit.SECONDS) .build(); } @@ -194,7 +193,6 @@ protected PropertyDescriptor getSupportedDynamicPropertyDescriptor(final String .name(propertyDescriptorName) .expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES) .addValidator(StandardValidators.ATTRIBUTE_KEY_PROPERTY_NAME_VALIDATOR) - .dependsOn(AddressesAccessUtils.PLC_ADDRESS_ACCESS_STRATEGY, AddressesAccessUtils.ADDRESS_PROPERTY) .addValidator(new DynamicPropertyAccessStrategy.TagValidator(AddressesAccessUtils.getManager())) .required(false) .dynamic(true) @@ -209,7 +207,7 @@ public void onScheduled(final ProcessContext context) { schemaCache.restartCache(newCacheSize); cacheSize = newCacheSize; } - refreshConnectionManager(); + refreshConnectionCache(); debugEnabled = getLogger().isDebugEnabled(); addressAccessStrategy = AddressesAccessUtils.getAccessStrategy(context); } @@ -236,7 +234,7 @@ public int hashCode() { } protected PlcWriteRequest getWriteRequest(final ComponentLog logger, - final Map addressMap, final Map tags, final Map presentTags, + final Map addressMap, final Map tags, final Map presentTags, final PlcConnection connection, final AtomicLong nrOfRowsHere) { PlcWriteRequest.Builder builder = connection.writeRequestBuilder(); @@ -281,7 +279,7 @@ protected PlcReadRequest getReadRequest(final ComponentLog logger, } } else { if (debugEnabled) - logger.debug("Plc-Avro schema and PlcTypes resolution not found in cache and will be added with key: " + addressMap); + logger.debug("Schema and PlcTypes resolution not found in cache and will be added with key: " + addressMap); for (Map.Entry entry: addressMap.entrySet()){ builder.addTagAddress(entry.getKey(), entry.getValue()); } @@ -294,7 +292,7 @@ protected void evaluateWriteResponse(final ComponentLog logger, Map tagsAtError = null; - PlcResponseCode code = null; + PlcResponseCode code; for (String tag : plcWriteResponse.getTagNames()) { code = plcWriteResponse.getResponseCode(tag); @@ -302,9 +300,7 @@ protected void evaluateWriteResponse(final ComponentLog logger, Map(); } - logger.error("Not OK code when writing the data to PLC for tag " + tag - + " with value " + values.get(tag).toString() - + " in addresss " + plcWriteResponse.getTag(tag).getAddressString()); + logger.warn("Not OK code when writing the data to PLC for tag {} with value {}", tag, values.get(tag)); codeErrorPresent = true; tagsAtError.add(tag); @@ -312,7 +308,7 @@ protected void evaluateWriteResponse(final ComponentLog logger, Map pds = new ArrayList<>(); - pds.addAll(super.getSupportedPropertyDescriptors()); + final List pds = new ArrayList<>(super.getSupportedPropertyDescriptors()); pds.add(PLC_RECORD_WRITER_FACTORY); pds.add(PLC_SUBSCRIPTION_TYPE); pds.add(PLC_SUBSCRIPTION_CYCLIC_POLLING_INTERVAL); @@ -153,7 +152,6 @@ protected void createDispatcher(final ProcessContext context, final BlockingQueu } catch (Exception e) { if (debugEnabled) { getLogger().debug("Error creating a the subscription event dispatcher"); - e.printStackTrace(); } throw new ProcessException(e); } @@ -218,7 +216,7 @@ public void onTrigger(final ProcessContext context, final ProcessSession session try { session.write(resultSetFF, out -> { try { - nrOfRows.set(plc4xWriter.writePlcReadResponse(event, out, getLogger(), null, recordSchema, getTimestampField(context))); + nrOfRows.set(plc4xWriter.writePlcReadResponse(event, out, getLogger(), recordSchema, getTimestampField(context))); } catch (Exception e) { getLogger().error("Exception reading the data from PLC", e); throw (e instanceof ProcessException) ? (ProcessException) e : new ProcessException(e); @@ -241,11 +239,10 @@ public void onTrigger(final ProcessContext context, final ProcessSession session private void addTagsToCache(DefaultPlcSubscriptionEvent event, Plc4xWriter plc4xWriter) { if (debugEnabled) - getLogger().debug("Adding Plc-Avro schema and PlcTypes resolution into cache with key: " + addressMap.toString()); + getLogger().debug("Adding Schema and PlcTypes resolution into cache with key: " + addressMap.toString()); // Add schema to the cache - LinkedHashSet addressNames = new LinkedHashSet<>(); - addressNames.addAll(event.getTagNames()); + LinkedHashSet addressNames = new LinkedHashSet<>(event.getTagNames()); List addressTags = addressNames.stream().map(addr -> new PlcTag() { @@ -293,7 +290,7 @@ private FlowFile completeResultFlowFile(final ProcessSession session, final Atom protected static class CyclycPollingIntervalValidator implements Validator { @Override public ValidationResult validate(String subject, String input, ValidationContext context) { - if (context.getProperty(PLC_FUTURE_TIMEOUT_MILISECONDS).asLong() > Long.valueOf(input)) { + if (context.getProperty(PLC_FUTURE_TIMEOUT_MILISECONDS).asLong() > Long.parseLong(input)) { return new ValidationResult.Builder().valid(true).build(); } else { return new ValidationResult.Builder() diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSinkProcessor.java b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSinkProcessor.java similarity index 73% rename from plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSinkProcessor.java rename to plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSinkProcessor.java index 416b010fb..a7ba0621c 100644 --- a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSinkProcessor.java +++ b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSinkProcessor.java @@ -18,7 +18,10 @@ */ package org.apache.plc4x.nifi; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.Map; +import java.util.Optional; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -60,7 +63,7 @@ public void onTrigger(final ProcessContext context, final ProcessSession session final ComponentLog logger = getLogger(); - try(PlcConnection connection = getConnectionManager().getConnection(getConnectionString(context, flowFile))) { + try(PlcConnection connection = getConnectionCache().getConnection(getConnectionString(context, flowFile))) { if (!connection.getMetadata().isWriteSupported()) { throw new ProcessException("Writing not supported by connection"); } @@ -74,30 +77,36 @@ public void onTrigger(final ProcessContext context, final ProcessSession session final PlcWriteResponse plcWriteResponse = writeRequest.execute().get(getTimeout(context, flowFile), TimeUnit.MILLISECONDS); evaluateWriteResponse(logger, flowFile.getAttributes(), plcWriteResponse); - + + // Store all configured tags in cache if not present + if (tags == null) { + if (debugEnabled) + logger.debug("Adding PlcTypes resolution into cache with key: " + addressMap); + + // Parse plc addresses + Map validAddressesPlcTags = new LinkedHashMap<>(); + for (Map.Entry entry : addressMap.entrySet()) { + Optional newTag = connection.parseTagAddress(entry.getValue()); + newTag.ifPresent(parsed -> validAddressesPlcTags.put(entry.getKey(), parsed)); + } + + getSchemaCache().addSchema( + addressMap, + validAddressesPlcTags.keySet(), + new ArrayList<>(validAddressesPlcTags.values()), + null + ); + } } catch (TimeoutException e) { - logger.error("Timeout writting the data to the PLC", e); - getConnectionManager().removeCachedConnection(getConnectionString(context, flowFile)); + logger.error("Timeout writing the data to the PLC", e); + getConnectionCache().removeCachedConnection(getConnectionString(context, flowFile)); throw new ProcessException(e); } catch (Exception e) { - logger.error("Exception writting the data to the PLC", e); + logger.error("Exception writing the data to the PLC", e); throw (e instanceof ProcessException) ? (ProcessException) e : new ProcessException(e); } session.transfer(flowFile, REL_SUCCESS); - - if (tags == null){ - if (debugEnabled) - logger.debug("Adding PlcTypes resolution into cache with key: " + addressMap); - getSchemaCache().addSchema( - addressMap, - writeRequest.getTagNames(), - writeRequest.getTags(), - null - ); - } - - } catch (Exception e) { flowFile = session.putAttribute(flowFile, EXCEPTION, e.getLocalizedMessage()); session.transfer(flowFile, REL_FAILURE); diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSinkRecordProcessor.java b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSinkRecordProcessor.java similarity index 88% rename from plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSinkRecordProcessor.java rename to plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSinkRecordProcessor.java index 661f91926..2ce2b9f67 100644 --- a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSinkRecordProcessor.java +++ b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSinkRecordProcessor.java @@ -22,8 +22,10 @@ Licensed to the Apache Software Foundation (ASF) under one import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -126,7 +128,7 @@ public void onTrigger(final ProcessContext context, final ProcessSession session final Map addressMap = getPlcAddressMap(context, fileToProcess); final Map tags = getSchemaCache().retrieveTags(addressMap); - try (PlcConnection connection = getConnectionManager().getConnection(getConnectionString(context, fileToProcess))) { + try (PlcConnection connection = getConnectionCache().getConnection(getConnectionString(context, fileToProcess))) { writeRequest = getWriteRequest(logger, addressMap, tags, record.toMap(), connection, nrOfRowsHere); @@ -135,9 +137,28 @@ public void onTrigger(final ProcessContext context, final ProcessSession session // Response check if values were written evaluateWriteResponse(logger, record.toMap(), plcWriteResponse); + // Store all configured tags in cache if not present + if (tags == null) { + if (debugEnabled) + logger.debug("Adding PlcTypes resolution into cache with key: " + addressMap); + + // Parse plc addresses + Map validAddressesPlcTags = new LinkedHashMap<>(); + for (Map.Entry entry : addressMap.entrySet()) { + Optional newTag = connection.parseTagAddress(entry.getValue()); + newTag.ifPresent(parsed -> validAddressesPlcTags.put(entry.getKey(), parsed)); + } + + getSchemaCache().addSchema( + addressMap, + validAddressesPlcTags.keySet(), + new ArrayList<>(validAddressesPlcTags.values()), + null + ); + } } catch (TimeoutException e) { logger.error("Timeout writting the data to the PLC", e); - getConnectionManager().removeCachedConnection(getConnectionString(context, fileToProcess)); + getConnectionCache().removeCachedConnection(getConnectionString(context, fileToProcess)); throw new ProcessException(e); } catch (PlcConnectionException e) { logger.error("Error getting the PLC connection", e); @@ -146,20 +167,8 @@ public void onTrigger(final ProcessContext context, final ProcessSession session logger.error("Exception writting the data to the PLC", e); throw (e instanceof ProcessException) ? (ProcessException) e : new ProcessException(e); } - - if (tags == null){ - if (debugEnabled) - logger.debug("Adding PlcTypes resolution into cache with key: " + addressMap); - getSchemaCache().addSchema( - addressMap, - writeRequest.getTagNames(), - writeRequest.getTags(), - null - ); - } - nrOfRows.getAndAdd(nrOfRowsHere.get()); - + nrOfRows.getAndAdd(nrOfRowsHere.get()); } } catch (Exception e) { throw (e instanceof ProcessException) ? (ProcessException) e : new ProcessException(e); diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSourceProcessor.java b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSourceProcessor.java similarity index 92% rename from plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSourceProcessor.java rename to plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSourceProcessor.java index 9e02be0e4..85e63051f 100644 --- a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSourceProcessor.java +++ b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSourceProcessor.java @@ -61,7 +61,13 @@ public void onTrigger(final ProcessContext context, final ProcessSession session final ComponentLog logger = getLogger(); final FlowFile flowFile = session.create(); - try(PlcConnection connection = getConnectionManager().getConnection(getConnectionString(context, incomingFlowFile))) { + final String connectionString = getConnectionString(context, incomingFlowFile); + + if (debugEnabled) { + logger.debug("Get connection for plc: {}", connectionString); + } + + try(PlcConnection connection = getConnectionCache().getConnection(connectionString)) { if (!connection.getMetadata().isReadSupported()) { throw new ProcessException("Reading not supported by connection"); @@ -80,7 +86,7 @@ public void onTrigger(final ProcessContext context, final ProcessSession session } catch (TimeoutException e) { logger.error("Timeout reading the data from PLC", e); - getConnectionManager().removeCachedConnection(getConnectionString(context, incomingFlowFile)); + getConnectionCache().removeCachedConnection(getConnectionString(context, incomingFlowFile)); throw new ProcessException(e); } catch (Exception e) { logger.error("Exception reading the data from PLC", e); diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSourceRecordProcessor.java b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSourceRecordProcessor.java similarity index 95% rename from plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSourceRecordProcessor.java rename to plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSourceRecordProcessor.java index c2e53e762..40ccd32cf 100644 --- a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSourceRecordProcessor.java +++ b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSourceRecordProcessor.java @@ -130,9 +130,15 @@ public void onTrigger(final ProcessContext context, final ProcessSession session final RecordSchema recordSchema = getSchemaCache().retrieveSchema(addressMap); final Map tags = getSchemaCache().retrieveTags(addressMap); PlcReadRequest readRequest; - Long nrOfRowsHere; + long nrOfRowsHere; - try (PlcConnection connection = getConnectionManager().getConnection(getConnectionString(context, originalFlowFile))) { + final String connectionString = getConnectionString(context, originalFlowFile); + + if (debugEnabled) { + logger.debug("Get connection for plc: {}", connectionString); + } + + try (PlcConnection connection = getConnectionCache().getConnection(connectionString)) { readRequest = getReadRequest(logger, addressMap, tags, connection); @@ -142,7 +148,7 @@ public void onTrigger(final ProcessContext context, final ProcessSession session } catch (TimeoutException e) { logger.error("Timeout reading the data from PLC", e); - getConnectionManager().removeCachedConnection(getConnectionString(context, originalFlowFile)); + getConnectionCache().removeCachedConnection(connectionString); throw new ProcessException(e); } catch (PlcConnectionException e) { logger.error("Error getting the PLC connection", e); diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/AddressesAccessStrategy.java b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/AddressesAccessStrategy.java similarity index 100% rename from plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/AddressesAccessStrategy.java rename to plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/AddressesAccessStrategy.java diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/AddressesAccessUtils.java b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/AddressesAccessUtils.java similarity index 97% rename from plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/AddressesAccessUtils.java rename to plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/AddressesAccessUtils.java index 9b63d584b..d6e15046a 100644 --- a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/AddressesAccessUtils.java +++ b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/AddressesAccessUtils.java @@ -27,7 +27,7 @@ public class AddressesAccessUtils { - private static DefaultPlcDriverManager manager = new DefaultPlcDriverManager(); + private static final DefaultPlcDriverManager manager = new DefaultPlcDriverManager(); public static DefaultPlcDriverManager getManager() { return manager; diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/BaseAccessStrategy.java b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/BaseAccessStrategy.java similarity index 98% rename from plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/BaseAccessStrategy.java rename to plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/BaseAccessStrategy.java index c94d7d314..8bb2d96be 100644 --- a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/BaseAccessStrategy.java +++ b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/BaseAccessStrategy.java @@ -74,7 +74,7 @@ public Map extractAddresses(final ProcessContext context, final public static class TagValidator implements Validator { - private DefaultPlcDriverManager manager; + private final DefaultPlcDriverManager manager; public TagValidator(DefaultPlcDriverManager manager) { this.manager = manager; diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/DynamicPropertyAccessStrategy.java b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/DynamicPropertyAccessStrategy.java similarity index 100% rename from plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/DynamicPropertyAccessStrategy.java rename to plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/DynamicPropertyAccessStrategy.java diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/FilePropertyAccessStrategy.java b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/FilePropertyAccessStrategy.java similarity index 99% rename from plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/FilePropertyAccessStrategy.java rename to plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/FilePropertyAccessStrategy.java index b3214375b..995ae0be5 100644 --- a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/FilePropertyAccessStrategy.java +++ b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/FilePropertyAccessStrategy.java @@ -20,9 +20,8 @@ import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; - -import java.nio.file.StandardOpenOption; import java.nio.file.Path; +import java.nio.file.StandardOpenOption; import java.util.Collection; import java.util.List; import java.util.Map; diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/TextPropertyAccessStrategy.java b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/TextPropertyAccessStrategy.java similarity index 100% rename from plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/TextPropertyAccessStrategy.java rename to plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/TextPropertyAccessStrategy.java diff --git a/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/Plc4xReadResponseRecordSet.java b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/Plc4xReadResponseRecordSet.java new file mode 100644 index 000000000..e7630bb3f --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/Plc4xReadResponseRecordSet.java @@ -0,0 +1,192 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.nifi.record; + +import java.io.Closeable; +import java.io.IOException; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.nifi.logging.ComponentLog; +import org.apache.nifi.serialization.record.MapRecord; +import org.apache.nifi.serialization.record.Record; +import org.apache.nifi.serialization.record.RecordField; +import org.apache.nifi.serialization.record.RecordSchema; +import org.apache.nifi.serialization.record.RecordSet; +import org.apache.plc4x.java.api.messages.PlcReadResponse; +import org.apache.plc4x.java.api.types.PlcResponseCode; +import org.apache.plc4x.java.api.value.PlcValue; +import org.apache.plc4x.java.spi.drivers.messages.DefaultPlcSubscriptionEvent; +import org.apache.plc4x.java.spi.drivers.messages.items.PlcResponseItem; +import org.apache.plc4x.nifi.util.Plc4xCommon; + +public class Plc4xReadResponseRecordSet implements RecordSet, Closeable { + private final ComponentLog logger; + private final PlcReadResponse readResponse; + private final Set rsColumnNames; + private boolean moreRows; + private final String timestampFieldName; + private boolean isSubscription = false; + private Instant timestamp; + + private final AtomicReference recordSchema = new AtomicReference<>(null); + + public Plc4xReadResponseRecordSet(final PlcReadResponse readResponse, RecordSchema recordSchema, String timestampFieldName, ComponentLog logger) { + this.timestampFieldName = timestampFieldName; + this.readResponse = readResponse; + this.logger = logger; + + if (!isSubscription) { + timestamp = Instant.now(); + } + moreRows = true; + + isSubscription = readResponse.getRequest() == null; + + if (this.logger.isDebugEnabled()) + logger.debug("Creating record schema from PlcReadResponse"); + + Map responseDataStructure; + + responseDataStructure = !isSubscription? + readResponse.getAsPlcValue().getStruct(): + plc4xSubscriptionResponseRecordSet((DefaultPlcSubscriptionEvent) readResponse); + + rsColumnNames = responseDataStructure.keySet(); + + if (recordSchema == null) { + RecordSchema schema = Plc4xCommon.createSchema(responseDataStructure, this.timestampFieldName); + this.recordSchema.set(schema); + } else { + this.recordSchema.set(recordSchema); + } + if (this.logger.isDebugEnabled()) + logger.debug("Record schema from PlcReadResponse successfuly created."); + + } + + public Map plc4xSubscriptionResponseRecordSet(final DefaultPlcSubscriptionEvent subscriptionEvent) { + moreRows = true; + + if (logger.isDebugEnabled()) + logger.debug("Creating record schema from DefaultPlcSubscriptionEvent"); + + Map responseDataStructure = new HashMap<>(); + + for (String tagName : subscriptionEvent.getTagNames()) { + PlcValue plcValue = subscriptionEvent.getPlcValue(tagName); + responseDataStructure.put(tagName, plcValue); + } + + return responseDataStructure; + } + + + @Override + public RecordSchema getSchema() { + return this.recordSchema.get(); + } + + // Protected methods for subclasses to access private member variables + protected PlcReadResponse getReadResponse() { + return readResponse; + } + + protected boolean hasMoreRows() { + return moreRows; + } + + protected void setMoreRows(boolean moreRows) { + this.moreRows = moreRows; + } + + @Override + public Record next() throws IOException { + if (moreRows) { + Record record; + + record = createRecord(readResponse); + + setMoreRows(false); + return record; + } else { + return null; + } + } + + @Override + public void close() { + //do nothing + } + + protected Record createRecord(final PlcReadResponse readResponse) { + final Map values = new HashMap<>(getSchema().getFieldCount()); + + if (logger.isDebugEnabled()) + logger.debug("Creating record from plc response"); + + for (final RecordField tag : getSchema().getFields()) { + final String tagName = tag.getFieldName(); + + final Object value; + + if (rsColumnNames.contains(tagName)) { + if (!isSubscription) { + value = normalizeValue(readResponse.getAsPlcValue().getValue(tagName)); + } else { + value = normalizeValue(readResponse.getPlcValue(tagName)); + } + + } else { + value = null; + } + + if (logger.isDebugEnabled()) + logger.debug("Adding {} tag value to record.", tagName); + values.put(tagName, value); + } + + //add timestamp tag to schema + if (isSubscription) { + values.put(timestampFieldName, ((DefaultPlcSubscriptionEvent) readResponse).getTimestamp().toEpochMilli()); + } else { + values.put(timestampFieldName, timestamp.toEpochMilli()); + } + + if (logger.isDebugEnabled()) + logger.debug("Adding timestamp tag {} to record.", timestampFieldName); + + + return new MapRecord(getSchema(), values); + } + + private Object normalizeValue(final PlcValue value) { + Object r = Plc4xCommon.normalizeValue(value); + if (r != null) { + logger.trace("Value data type: {}", r.getClass()); + } + return r; + + } + + +} diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/Plc4xWriter.java b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/Plc4xWriter.java similarity index 88% rename from plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/Plc4xWriter.java rename to plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/Plc4xWriter.java index 1d774e79e..534add9da 100644 --- a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/Plc4xWriter.java +++ b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/Plc4xWriter.java @@ -32,15 +32,14 @@ public interface Plc4xWriter { /** * Writes the given result set out to the given output stream, possibly applying a callback as each row is processed. - * @param resultSet the ResultSet to be written + * @param response the PlcReadResponse to be written * @param outputStream the OutputStream to write the result set to * @param logger a common logger that can be used to log messages during write - * @param callback a MaxValueResultSetRowCollector that may be called as each row in the ResultSet is processed * @return the number of rows written to the output stream * @throws Exception if any errors occur during the writing of the result set to the output stream */ - long writePlcReadResponse(PlcReadResponse response, OutputStream outputStream, ComponentLog logger, Plc4xReadResponseRowCallback callback, RecordSchema recordSchema, String timestampFieldName) throws Exception; - long writePlcReadResponse(PlcReadResponse response, OutputStream outputStream, ComponentLog logger, Plc4xReadResponseRowCallback callback, RecordSchema recordSchema, FlowFile originalFlowFile, String timestampFieldName) throws Exception; + long writePlcReadResponse(PlcReadResponse response, OutputStream outputStream, ComponentLog logger, RecordSchema recordSchema, String timestampFieldName) throws Exception; + long writePlcReadResponse(PlcReadResponse response, OutputStream outputStream, ComponentLog logger, RecordSchema recordSchema, FlowFile originalFlowFile, String timestampFieldName) throws Exception; /** * Returns a map of attribute key/value pairs to be added to any outgoing flow file(s). The default implementation is to return an empty map. diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/RecordPlc4xWriter.java b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/RecordPlc4xWriter.java similarity index 90% rename from plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/RecordPlc4xWriter.java rename to plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/RecordPlc4xWriter.java index f1e314b5d..ee22588a9 100644 --- a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/RecordPlc4xWriter.java +++ b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/RecordPlc4xWriter.java @@ -56,10 +56,10 @@ public RecordPlc4xWriter(RecordSetWriterFactory recordSetWriterFactory, Map empty = new HashMap<>(); @@ -76,10 +76,10 @@ public long writePlcReadResponse(PlcReadResponse response, OutputStream outputSt @Override public long writePlcReadResponse(PlcReadResponse response, OutputStream outputStream, ComponentLog logger, - Plc4xReadResponseRowCallback callback, RecordSchema recordSchema, FlowFile originalFlowFile, String timestampFieldName) throws Exception { + RecordSchema recordSchema, FlowFile originalFlowFile, String timestampFieldName) throws Exception { - if (fullRecordSet == null) { - fullRecordSet = new Plc4xReadResponseRecordSetWithCallback(response, callback, recordSchema, timestampFieldName); + if (fullRecordSet == null) { + fullRecordSet = new Plc4xReadResponseRecordSetWithCallback(response, recordSchema, timestampFieldName, logger); writeSchema = recordSetWriterFactory.getSchema(originalAttributes, fullRecordSet.getSchema()); } @@ -160,13 +160,11 @@ public void updateCounters(ProcessSession session) { } private static class Plc4xReadResponseRecordSetWithCallback extends Plc4xReadResponseRecordSet { - private final Plc4xReadResponseRowCallback callback; - public Plc4xReadResponseRecordSetWithCallback(final PlcReadResponse readResponse, Plc4xReadResponseRowCallback callback, - RecordSchema recordSchema, String timestampFieldName) { + public Plc4xReadResponseRecordSetWithCallback(final PlcReadResponse readResponse, + RecordSchema recordSchema, String timestampFieldName, ComponentLog logger) { - super(readResponse, recordSchema, timestampFieldName); - this.callback = callback; + super(readResponse, recordSchema, timestampFieldName, logger); } @Override @@ -175,9 +173,6 @@ public Record next() throws IOException { PlcReadResponse response = getReadResponse(); final Record record = createRecord(response); setMoreRows(false); - if (callback != null) { - callback.processRow(response); - } return record; } else { return null; diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/SchemaCache.java b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/SchemaCache.java similarity index 92% rename from plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/SchemaCache.java rename to plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/SchemaCache.java index df80ffd6d..b0078922b 100644 --- a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/SchemaCache.java +++ b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/SchemaCache.java @@ -61,7 +61,8 @@ public void restartCache(int cacheSize) { * @param schema record schema used for PlcResponse serialization. Can be null */ public void addSchema(final Map schemaIdentifier, final Set tagsNames, final List tagsList, final RecordSchema schema) { - if (!schemaMap.containsKey(schemaIdentifier.toString())){ + final String identifier = schemaIdentifier.toString(); + if (!schemaMap.containsKey(identifier)){ if (nextSchemaPosition.get() == cacheSize.get()){ nextSchemaPosition.set(0); } @@ -71,8 +72,8 @@ public void addSchema(final Map schemaIdentifier, final Set schemaIdentifier, final Set tags; diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/subscription/Plc4xListenerDispatcher.java b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/subscription/Plc4xListenerDispatcher.java similarity index 79% rename from plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/subscription/Plc4xListenerDispatcher.java rename to plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/subscription/Plc4xListenerDispatcher.java index 549496cd4..80a7833aa 100644 --- a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/subscription/Plc4xListenerDispatcher.java +++ b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/subscription/Plc4xListenerDispatcher.java @@ -29,7 +29,7 @@ import org.apache.nifi.processor.exception.ProcessException; import org.apache.plc4x.java.DefaultPlcDriverManager; import org.apache.plc4x.java.api.PlcConnection; -import org.apache.plc4x.java.api.PlcConnectionManager; +import org.apache.plc4x.java.api.PlcConnectionFactory; import org.apache.plc4x.java.api.exceptions.PlcConnectionException; import org.apache.plc4x.java.api.exceptions.PlcProtocolException; import org.apache.plc4x.java.api.messages.PlcSubscriptionEvent; @@ -39,15 +39,15 @@ public class Plc4xListenerDispatcher implements Runnable { - private PlcConnectionManager connectionManager; - private Plc4xSubscriptionType subscriptionType; - private Long cyclingPollingInterval; - private ComponentLog logger; + private final PlcConnectionFactory connectionFactory; + private final Plc4xSubscriptionType subscriptionType; + private final Long cyclingPollingInterval; + private final ComponentLog logger; private boolean running = false; - private BlockingQueue events; + private final BlockingQueue events; private PlcConnection connection; - private Long timeout; - private BlockingQueue queuedEvents; + private final Long timeout; + private final BlockingQueue queuedEvents; public boolean isRunning() { return running; @@ -60,7 +60,7 @@ public Plc4xListenerDispatcher(Long timeout, Plc4xSubscriptionType subscriptionT this.logger = logger; this.events = events; this.queuedEvents = new LinkedBlockingQueue<>(); - this.connectionManager = new DefaultPlcDriverManager(); + this.connectionFactory = new DefaultPlcDriverManager(); } /** @@ -69,16 +69,20 @@ public Plc4xListenerDispatcher(Long timeout, Plc4xSubscriptionType subscriptionT * @param plcConnectionString the connection string for the to the plc * @param tags a map of tag identifier and tag address the * dispatcher will try subscribing - * @throws Exception - * @throws PlcConnectionException + * @throws Exception exception + * @throws PlcConnectionException connection exception */ public void open(String plcConnectionString, Map tags) throws PlcConnectionException, Exception { - connection = connectionManager.getConnection(plcConnectionString); + connection = connectionFactory.getConnection(plcConnectionString); if (!connection.getMetadata().isSubscribeSupported()) { throw new PlcProtocolException("This connection does not support subscription"); } + if (logger.isDebugEnabled()){ + logger.debug("Creating PLC {} subscription for connection {} with tags {}", subscriptionType, plcConnectionString, tags); + } + PlcSubscriptionRequest.Builder builder = connection.subscriptionRequestBuilder(); for (Map.Entry entry : tags.entrySet()) { @@ -96,6 +100,9 @@ public void open(String plcConnectionString, Map tags) throws Pl PlcSubscriptionRequest subscriptionRequest = builder.build(); PlcSubscriptionResponse subscriptionResponse; try { + if (logger.isDebugEnabled()){ + logger.debug("Submitting PLC {} subscription for connection {} with tags {}", subscriptionType, plcConnectionString, tags); + } subscriptionResponse = subscriptionRequest.execute().get(timeout, TimeUnit.MILLISECONDS); } catch (InterruptedException e) { @@ -109,10 +116,12 @@ public void open(String plcConnectionString, Map tags) throws Pl throw (e instanceof ProcessException) ? (ProcessException) e : new ProcessException(e); } + + if (logger.isDebugEnabled()){ + logger.debug("Registering handlers for PLC {} subscription for connection {} with tags {}", subscriptionType, plcConnectionString, tags); + } for (PlcSubscriptionHandle handle : subscriptionResponse.getSubscriptionHandles()) { - handle.register(plcSubscriptionEvent -> { - queuedEvents.offer(plcSubscriptionEvent); - }); + handle.register(queuedEvents::offer); } running = true; @@ -122,6 +131,9 @@ public void open(String plcConnectionString, Map tags) throws Pl * Closes all listeners and stops all handler threads. */ public void close() { + if (logger.isDebugEnabled()) { + logger.debug("Closing listener for "); + } running = false; try { connection.close(); diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/subscription/Plc4xSubscriptionType.java b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/subscription/Plc4xSubscriptionType.java similarity index 100% rename from plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/subscription/Plc4xSubscriptionType.java rename to plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/subscription/Plc4xSubscriptionType.java diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/util/Plc4xCommon.java b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/util/Plc4xCommon.java similarity index 60% rename from plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/util/Plc4xCommon.java rename to plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/util/Plc4xCommon.java index 00075f6cf..f936b1b59 100644 --- a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/util/Plc4xCommon.java +++ b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/util/Plc4xCommon.java @@ -18,15 +18,15 @@ */ package org.apache.plc4x.nifi.util; +import java.util.ArrayList; import java.util.List; import java.util.Map; -import org.apache.avro.Schema; -import org.apache.avro.SchemaBuilder; -import org.apache.avro.SchemaBuilder.BaseTypeBuilder; -import org.apache.avro.SchemaBuilder.FieldAssembler; -import org.apache.avro.SchemaBuilder.NullDefault; -import org.apache.avro.SchemaBuilder.UnionAccumulator; +import org.apache.nifi.serialization.SimpleRecordSchema; +import org.apache.nifi.serialization.record.DataType; +import org.apache.nifi.serialization.record.RecordField; +import org.apache.nifi.serialization.record.RecordFieldType; +import org.apache.nifi.serialization.record.RecordSchema; import org.apache.plc4x.java.api.value.PlcValue; import org.apache.plc4x.java.spi.values.PlcBOOL; import org.apache.plc4x.java.spi.values.PlcBYTE; @@ -53,65 +53,95 @@ public class Plc4xCommon { + private Plc4xCommon (){} + /** - * This method is used to infer output AVRO schema directly from the PlcReadResponse object. + * This method is used to create a NiFi record schema from the PlcReadResponse object. * It is directly used from the RecordPlc4xWriter.writePlcReadResponse() method. * However, to make sure output schema does not change, it is built from the processor configuration (variable memory addresses). - * * At the moment this method does not handle the following Object Types: PlcValueAdapter, PlcIECValue, PlcSimpleValue * * @param responseDataStructure: a map that reflects the structure of the answer given by the PLC when making a Read Request. - * @return AVRO Schema built from responseDataStructure. + * @return RecordSchema built from responseDataStructure. */ - public static Schema createSchema(Map responseDataStructure, String timestampFieldName){ - //plc and record datatype map - final FieldAssembler builder = SchemaBuilder.record("PlcReadResponse").namespace("any.data").fields(); - String fieldName = null; - + public static RecordSchema createSchema(Map responseDataStructure, String timestampFieldName){ + List recordFields = new ArrayList<>(); + for (Map.Entry entry : responseDataStructure.entrySet()) { - fieldName = entry.getKey(); - PlcValue value = entry.getValue(); - BaseTypeBuilder>> fieldBuilder = - builder.name(fieldName).type().unionOf().nullType().and(); - - if (value instanceof PlcList) { - if(!value.getList().isEmpty()) { - fieldBuilder = fieldBuilder.array().items(); - value = value.getList().get(0); - } - } + RecordField f = new RecordField(entry.getKey(), getDataType(entry.getValue())); + recordFields.add(f); + } + + recordFields.add(new RecordField(timestampFieldName, RecordFieldType.BIGINT.getDataType())); + + return new SimpleRecordSchema(recordFields); + } - // PlcTYPEs not in here are casted to avro string type. - UnionAccumulator> buildedField = null; - if (value instanceof PlcBOOL) { - buildedField = fieldBuilder.booleanType(); - }else if (value instanceof PlcBYTE) { - buildedField = fieldBuilder.bytesType(); - }else if (value instanceof PlcINT) { - buildedField = fieldBuilder.intType(); - }else if (value instanceof PlcLINT) { - buildedField = fieldBuilder.longType(); - }else if (value instanceof PlcLREAL) { - buildedField = fieldBuilder.doubleType(); - }else if (value instanceof PlcREAL) { - buildedField = fieldBuilder.floatType(); - }else if (value instanceof PlcSINT) { - buildedField = fieldBuilder.intType(); - }else {// Default to string: - fieldBuilder.stringType().endUnion().nullDefault(); - continue;// In case of null default continue + private static DataType getDataType(final Object valueOriginal) { + + PlcValue value = (PlcValue) valueOriginal; + + // Lists. Inner data type default to STRING if empty list + if (value instanceof PlcList && value.isList()){ + if (!value.getList().isEmpty()) { + return RecordFieldType.ARRAY.getArrayDataType(getDataType(value.getList().get(0))); } - buildedField.endUnion().noDefault(); + return RecordFieldType.ARRAY.getArrayDataType(RecordFieldType.STRING.getDataType()); } + + // 8 bits + if (value instanceof PlcBOOL && value.isBoolean()) + return RecordFieldType.BOOLEAN.getDataType(); + if (value instanceof PlcBYTE && (value.isByte() || value.isShort())) + return RecordFieldType.SHORT.getDataType(); + if (value instanceof PlcCHAR && value.isShort()) + return RecordFieldType.STRING.getDataType(); + if ((value instanceof PlcSINT || value instanceof PlcUSINT) && (value.isShort() || value.isInteger())) + return RecordFieldType.SHORT.getDataType(); + + + // 16 bits + if (value instanceof PlcWORD && (value.isInteger() || value.isShort())) + return RecordFieldType.STRING.getDataType(); + if (value instanceof PlcINT && value.isInteger()) + return RecordFieldType.INT.getDataType(); + if (value instanceof PlcUINT && value.isInteger()) + return RecordFieldType.INT.getDataType(); + if (value instanceof PlcWCHAR || value instanceof PlcDWORD) + return RecordFieldType.STRING.getDataType(); + + // 32 bits + if (value instanceof PlcREAL && value.isFloat()) + return RecordFieldType.FLOAT.getDataType(); + if ((value instanceof PlcDINT) && value.isInteger()) + return RecordFieldType.INT.getDataType(); + if (value instanceof PlcDWORD && value.isInteger()) + return RecordFieldType.STRING.getDataType(); - //add timestamp tag to schema - builder.name(timestampFieldName).type().longType().noDefault(); - - - return builder.endRecord(); + // 64 bits + if ((value instanceof PlcLINT || value instanceof PlcUDINT) && value.isLong()) + return RecordFieldType.LONG.getDataType(); + if (value instanceof PlcULINT) + return RecordFieldType.BIGINT.getDataType(); + if (value instanceof PlcLREAL && value.isDouble()) + return RecordFieldType.DOUBLE.getDataType(); + if (value instanceof PlcLWORD && (value.isLong() || value.isBigInteger())) + return RecordFieldType.STRING.getDataType(); + + // Dates and time + if (value instanceof PlcDATE && value.isDate()) + return RecordFieldType.DATE.getDataType(); + if (value instanceof PlcDATE_AND_TIME && value.isDateTime()) + return RecordFieldType.TIME.getDataType(); + if (value instanceof PlcTIME && value.isTime()) + return RecordFieldType.TIME.getDataType(); + if (value instanceof PlcTIME_OF_DAY && value.isTime()) + return RecordFieldType.TIME.getDataType(); + + // Everything else to string + return RecordFieldType.STRING.getDataType(); } - private static Object normalizeBasicTypes(final Object valueOriginal) { if (valueOriginal == null) return null; @@ -125,7 +155,7 @@ private static Object normalizeBasicTypes(final Object valueOriginal) { return new byte[]{value.getByte()}; if (value instanceof PlcCHAR && value.isShort()) return value.getString(); - if ((value instanceof PlcSINT || value instanceof PlcUSINT) && value.isShort()) + if ((value instanceof PlcSINT || value instanceof PlcUSINT) && (value.isShort() || value.isInteger())) return value.getShort(); diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor similarity index 100% rename from plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor rename to plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xListenRecordProcessorTest.java b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xListenRecordProcessorTest.java similarity index 91% rename from plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xListenRecordProcessorTest.java rename to plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xListenRecordProcessorTest.java index c79b69fcf..8a5794e74 100644 --- a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xListenRecordProcessorTest.java +++ b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xListenRecordProcessorTest.java @@ -25,7 +25,6 @@ Licensed to the Apache Software Foundation (ASF) under one import org.apache.plc4x.nifi.address.AddressesAccessUtils; import org.apache.plc4x.nifi.util.Plc4xCommonTest; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import com.fasterxml.jackson.core.JsonProcessingException; @@ -53,16 +52,18 @@ public void init() throws InitializationException { testRunner.addControllerService("writer", writerService); testRunner.enableControllerService(writerService); testRunner.setProperty(Plc4xListenRecordProcessor.PLC_RECORD_WRITER_FACTORY.getName(), "writer"); + + Plc4xCommonTest.setLogger(testRunner.getLogger()); } public void testAvroRecordWriterProcessor() throws InitializationException { testRunner.run(NUMBER_OF_CALLS,true, true); //validations - Plc4xCommonTest.assertAvroContent(testRunner.getFlowFilesForRelationship(Plc4xSourceProcessor.REL_SUCCESS), false, true); + Plc4xCommonTest.assertContent(testRunner.getFlowFilesForRelationship(Plc4xSourceProcessor.REL_SUCCESS), false, true); } // Test dynamic properties addressess access strategy - @Disabled // Until simulated driver supports subscription + // Until simulated driver supports subscription @Test public void testWithAddressProperties() throws InitializationException { testRunner.setProperty(AddressesAccessUtils.PLC_ADDRESS_ACCESS_STRATEGY, AddressesAccessUtils.ADDRESS_PROPERTY); @@ -70,7 +71,7 @@ public void testWithAddressProperties() throws InitializationException { } // Test addressess text property access strategy - @Disabled // Until simulated driver supports subscription + // Until simulated driver supports subscription @Test public void testWithAddressText() throws InitializationException, JsonProcessingException { testRunner.setProperty(AddressesAccessUtils.PLC_ADDRESS_ACCESS_STRATEGY, AddressesAccessUtils.ADDRESS_TEXT); diff --git a/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xNifiTest.java b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xNifiTest.java new file mode 100644 index 000000000..5c95490ff --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xNifiTest.java @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.nifi; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; + +import org.apache.plc4x.nifi.util.Plc4xCommonTest; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; + +import com.fasterxml.jackson.databind.ObjectMapper; + +public class Plc4xNifiTest { + + + private static File dumyAddressesFile; + + public static File getDumyAddressesFile() { + return dumyAddressesFile; + } + + @BeforeAll + public static void beforeAll() { + + dumyAddressesFile = new File("dumy-address-file"); + + try (FileWriter fileWriter = new FileWriter(dumyAddressesFile)) { + ObjectMapper mapper = new ObjectMapper(); + fileWriter.write(mapper.writeValueAsString(Plc4xCommonTest.getAddressMap())); + } catch (IOException e) { + e.printStackTrace(); + } + } + + @AfterAll + public static void afterAll() { + dumyAddressesFile.delete(); + } +} diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSinkProcessorTest.java b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSinkProcessorTest.java similarity index 90% rename from plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSinkProcessorTest.java rename to plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSinkProcessorTest.java index 61601724a..352cbf091 100644 --- a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSinkProcessorTest.java +++ b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSinkProcessorTest.java @@ -33,7 +33,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -public class Plc4xSinkProcessorTest { +public class Plc4xSinkProcessorTest extends Plc4xNifiTest{ private TestRunner testRunner; private static final int NUMBER_OF_CALLS = 5; @@ -53,40 +53,44 @@ public void init() { for (int i = 0; i String.valueOf(e.getValue())))); - } - public void testProcessor() { + Plc4xCommonTest.setLogger(testRunner.getLogger()); + } + public void runProcessorTest() { testRunner.run(NUMBER_OF_CALLS); testRunner.assertTransferCount(Plc4xSinkProcessor.REL_FAILURE, 0); testRunner.assertTransferCount(Plc4xSinkProcessor.REL_SUCCESS, NUMBER_OF_CALLS); } + // @Disabled @Test public void testWithAddressProperties() { testRunner.setProperty(AddressesAccessUtils.PLC_ADDRESS_ACCESS_STRATEGY, AddressesAccessUtils.ADDRESS_PROPERTY); Plc4xCommonTest.getAddressMap().forEach((k,v) -> testRunner.setProperty(k, v)); - testProcessor(); + runProcessorTest(); } // Test addressess text property access strategy + // @Disabled @Test public void testWithAddressText() throws JsonProcessingException { testRunner.setProperty(AddressesAccessUtils.PLC_ADDRESS_ACCESS_STRATEGY, AddressesAccessUtils.ADDRESS_TEXT); testRunner.setProperty(AddressesAccessUtils.ADDRESS_TEXT_PROPERTY, new ObjectMapper().writeValueAsString(Plc4xCommonTest.getAddressMap())); - testProcessor(); + runProcessorTest(); } // Test addressess file property access strategy + // @Disabled @Test - public void testWithAdderessFile() throws InitializationException { + public void testWithAddressFile() throws InitializationException { testRunner.setProperty(AddressesAccessUtils.ADDRESS_FILE_PROPERTY, "file"); try (MockedStatic staticMock = Mockito.mockStatic(FilePropertyAccessStrategy.class)) { staticMock.when(() -> FilePropertyAccessStrategy.extractAddressesFromFile("file")) .thenReturn(Plc4xCommonTest.getAddressMap()); - testProcessor(); + runProcessorTest(); } } diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSinkRecordProcessorTest.java b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSinkRecordProcessorTest.java similarity index 66% rename from plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSinkRecordProcessorTest.java rename to plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSinkRecordProcessorTest.java index 9f22b62bc..4d2a91c44 100644 --- a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSinkRecordProcessorTest.java +++ b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSinkRecordProcessorTest.java @@ -18,32 +18,27 @@ Licensed to the Apache Software Foundation (ASF) under one */ package org.apache.plc4x.nifi; -import java.util.Map; - -import org.apache.nifi.avro.AvroReader; +import org.apache.nifi.json.JsonTreeReader; import org.apache.nifi.reporting.InitializationException; import org.apache.nifi.util.TestRunner; import org.apache.nifi.util.TestRunners; import org.apache.plc4x.nifi.address.AddressesAccessUtils; -import org.apache.plc4x.nifi.address.FilePropertyAccessStrategy; import org.apache.plc4x.nifi.util.Plc4xCommonTest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; -import org.mockito.Mockito; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -public class Plc4xSinkRecordProcessorTest { +public class Plc4xSinkRecordProcessorTest extends Plc4xNifiTest { private TestRunner testRunner; private static final int NUMBER_OF_CALLS = 5; - private final AvroReader readerService = new AvroReader(); + private final JsonTreeReader readerService = new JsonTreeReader(); @BeforeEach - public void init() throws InitializationException { + public void init() throws InitializationException, JsonProcessingException { testRunner = TestRunners.newTestRunner(Plc4xSinkRecordProcessor.class); testRunner.setIncomingConnection(false); testRunner.setValidateExpressionUsage(false); @@ -57,21 +52,15 @@ public void init() throws InitializationException { testRunner.addControllerService("reader", readerService); testRunner.enableControllerService(readerService); testRunner.setProperty(Plc4xSinkRecordProcessor.PLC_RECORD_READER_FACTORY.getName(), "reader"); - - for (Map.Entry address :Plc4xCommonTest.addressMap.entrySet()) { - // TODO: Random generation not working with this types - if (address.getValue().startsWith("RANDOM/")) { - if (address.getValue().endsWith("WORD")) - continue; - } - testRunner.setProperty(address.getKey(), address.getValue()); + + for (int i = 0; i testRunner.setProperty(k, v)); - testAvroRecordReaderProcessor(); + testProcessor(); } // Test addressess text property access strategy + // @Disabled @Test - public void testWithAddressText() throws InitializationException, JsonProcessingException { + public void testWithAddressText() throws JsonProcessingException { testRunner.setProperty(AddressesAccessUtils.PLC_ADDRESS_ACCESS_STRATEGY, AddressesAccessUtils.ADDRESS_TEXT); testRunner.setProperty(AddressesAccessUtils.ADDRESS_TEXT_PROPERTY, new ObjectMapper().writeValueAsString(Plc4xCommonTest.getAddressMap()).toString()); - testAvroRecordReaderProcessor(); + testProcessor(); } // Test addressess file property access strategy - @Test - public void testWithAdderessFile() throws InitializationException { - testRunner.setProperty(AddressesAccessUtils.ADDRESS_FILE_PROPERTY, "file"); - - try (MockedStatic staticMock = Mockito.mockStatic(FilePropertyAccessStrategy.class)) { - staticMock.when(() -> FilePropertyAccessStrategy.extractAddressesFromFile("file")) - .thenReturn(Plc4xCommonTest.getAddressMap()); + // @Disabled + @Test + public void testWithAdderessFile() { + testRunner.setProperty(AddressesAccessUtils.PLC_ADDRESS_ACCESS_STRATEGY, AddressesAccessUtils.ADDRESS_FILE); + testRunner.setProperty(AddressesAccessUtils.ADDRESS_FILE_PROPERTY, getDumyAddressesFile().getAbsolutePath()); - testAvroRecordReaderProcessor(); - } + testProcessor(); } } diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSourceProcessorTest.java b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSourceProcessorTest.java similarity index 86% rename from plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSourceProcessorTest.java rename to plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSourceProcessorTest.java index a5e79ceb0..0a3f672f6 100644 --- a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSourceProcessorTest.java +++ b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSourceProcessorTest.java @@ -18,6 +18,9 @@ */ package org.apache.plc4x.nifi; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + import org.apache.nifi.util.TestRunner; import org.apache.nifi.util.TestRunners; import org.apache.plc4x.nifi.address.AddressesAccessUtils; @@ -31,23 +34,25 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -public class Plc4xSourceProcessorTest { +public class Plc4xSourceProcessorTest extends Plc4xNifiTest { private TestRunner testRunner; private static final int NUMBER_OF_CALLS = 5; @BeforeEach - public void init() { + public void init() throws IllegalAccessException, IllegalArgumentException, InvocationTargetException, NoSuchMethodException, SecurityException { testRunner = TestRunners.newTestRunner(Plc4xSourceProcessor.class); testRunner.setIncomingConnection(false); testRunner.setValidateExpressionUsage(true); testRunner.setVariable("url", "simulated://127.0.0.1"); + testRunner.setProperty(Plc4xSourceProcessor.PLC_CONNECTION_STRING, "${url}"); testRunner.setProperty(Plc4xSourceProcessor.PLC_FUTURE_TIMEOUT_MILISECONDS, "1000"); testRunner.addConnection(Plc4xSourceProcessor.REL_SUCCESS); testRunner.addConnection(Plc4xSourceProcessor.REL_FAILURE); + Plc4xCommonTest.setLogger(testRunner.getLogger()); } public void testProcessor() { @@ -76,7 +81,8 @@ public void testWithAddressText() throws JsonProcessingException { // Test addressess file property access strategy @Test public void testWithAdderessFile() { - testRunner.setProperty(AddressesAccessUtils.ADDRESS_FILE_PROPERTY, "file"); + testRunner.setProperty(AddressesAccessUtils.PLC_ADDRESS_ACCESS_STRATEGY, AddressesAccessUtils.ADDRESS_FILE); + testRunner.setProperty(AddressesAccessUtils.ADDRESS_FILE_PROPERTY, getDumyAddressesFile().getAbsolutePath()); try (MockedStatic staticMock = Mockito.mockStatic(FilePropertyAccessStrategy.class)) { staticMock.when(() -> FilePropertyAccessStrategy.extractAddressesFromFile("file")) diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSourceRecordProcessorTest.java b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSourceRecordProcessorTest.java similarity index 72% rename from plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSourceRecordProcessorTest.java rename to plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSourceRecordProcessorTest.java index 5d47d0612..0f474bd77 100644 --- a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSourceRecordProcessorTest.java +++ b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSourceRecordProcessorTest.java @@ -18,27 +18,23 @@ Licensed to the Apache Software Foundation (ASF) under one */ package org.apache.plc4x.nifi; -import org.apache.nifi.avro.AvroRecordSetWriter; +import org.apache.nifi.json.JsonRecordSetWriter; import org.apache.nifi.reporting.InitializationException; import org.apache.nifi.util.TestRunner; import org.apache.nifi.util.TestRunners; import org.apache.plc4x.nifi.address.AddressesAccessUtils; -import org.apache.plc4x.nifi.address.FilePropertyAccessStrategy; import org.apache.plc4x.nifi.util.Plc4xCommonTest; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; -import org.mockito.Mockito; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -public class Plc4xSourceRecordProcessorTest { +public class Plc4xSourceRecordProcessorTest extends Plc4xNifiTest { private TestRunner testRunner; private static final int NUMBER_OF_CALLS = 5; - - private final AvroRecordSetWriter writerService = new AvroRecordSetWriter(); + private final JsonRecordSetWriter writerService = new JsonRecordSetWriter(); @BeforeEach public void init() throws InitializationException { @@ -56,43 +52,41 @@ public void init() throws InitializationException { testRunner.addControllerService("writer", writerService); testRunner.enableControllerService(writerService); testRunner.setProperty(Plc4xSourceRecordProcessor.PLC_RECORD_WRITER_FACTORY.getName(), "writer"); + + Plc4xCommonTest.setLogger(testRunner.getLogger()); } - public void testAvroRecordWriterProcessor() throws InitializationException { + public void testProcessor() { testRunner.run(NUMBER_OF_CALLS,true, true); //validations testRunner.assertTransferCount(Plc4xSourceRecordProcessor.REL_FAILURE, 0); testRunner.assertTransferCount(Plc4xSourceRecordProcessor.REL_SUCCESS, NUMBER_OF_CALLS); - Plc4xCommonTest.assertAvroContent(testRunner.getFlowFilesForRelationship(Plc4xSourceRecordProcessor.REL_SUCCESS), false, true); + Plc4xCommonTest.assertContent(testRunner.getFlowFilesForRelationship(Plc4xSourceRecordProcessor.REL_SUCCESS), false, true); } // Test dynamic properties addressess access strategy @Test - public void testWithAddressProperties() throws InitializationException { + public void testWithAddressProperties() { testRunner.setProperty(AddressesAccessUtils.PLC_ADDRESS_ACCESS_STRATEGY, AddressesAccessUtils.ADDRESS_PROPERTY); Plc4xCommonTest.getAddressMap().forEach((k,v) -> testRunner.setProperty(k, v)); - testAvroRecordWriterProcessor(); + testProcessor(); } // Test addressess text property access strategy @Test - public void testWithAddressText() throws InitializationException, JsonProcessingException { + public void testWithAddressText() throws JsonProcessingException { testRunner.setProperty(AddressesAccessUtils.PLC_ADDRESS_ACCESS_STRATEGY, AddressesAccessUtils.ADDRESS_TEXT); testRunner.setProperty(AddressesAccessUtils.ADDRESS_TEXT_PROPERTY, new ObjectMapper().writeValueAsString(Plc4xCommonTest.getAddressMap()).toString()); - testAvroRecordWriterProcessor(); + testProcessor(); } // Test addressess file property access strategy @Test - public void testWithAdderessFile() throws InitializationException { - testRunner.setProperty(AddressesAccessUtils.ADDRESS_FILE_PROPERTY, "file"); - - try (MockedStatic staticMock = Mockito.mockStatic(FilePropertyAccessStrategy.class)) { - staticMock.when(() -> FilePropertyAccessStrategy.extractAddressesFromFile("file")) - .thenReturn(Plc4xCommonTest.getAddressMap()); + public void testWithAdderessFile() { + testRunner.setProperty(AddressesAccessUtils.PLC_ADDRESS_ACCESS_STRATEGY, AddressesAccessUtils.ADDRESS_FILE); + testRunner.setProperty(AddressesAccessUtils.ADDRESS_FILE_PROPERTY, getDumyAddressesFile().getAbsolutePath()); - testAvroRecordWriterProcessor(); - } + testProcessor(); } } diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/address/AccessStrategyTest.java b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/address/AccessStrategyTest.java similarity index 100% rename from plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/address/AccessStrategyTest.java rename to plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/address/AccessStrategyTest.java diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/record/SchemaCacheTest.java b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/record/SchemaCacheTest.java similarity index 100% rename from plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/record/SchemaCacheTest.java rename to plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/record/SchemaCacheTest.java diff --git a/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/util/Plc4xCommonTest.java b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/util/Plc4xCommonTest.java new file mode 100644 index 000000000..3ac968e27 --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/util/Plc4xCommonTest.java @@ -0,0 +1,213 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +package org.apache.plc4x.nifi.util; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.nifi.json.JsonTreeRowRecordReader; +import org.apache.nifi.serialization.MalformedRecordException; +import org.apache.nifi.serialization.SimpleRecordSchema; +import org.apache.nifi.serialization.record.MapRecord; +import org.apache.nifi.serialization.record.Record; +import org.apache.nifi.serialization.record.RecordField; +import org.apache.nifi.serialization.record.RecordFieldType; +import org.apache.nifi.serialization.record.RecordSchema; +import org.apache.nifi.util.MockComponentLog; +import org.apache.nifi.util.MockFlowFile; + +public class Plc4xCommonTest { + // public static final Logger internalLogger = LoggerFactory.getLogger(Plc4xCommonTest.class); + public static MockComponentLog logger; + + public static final Map originalMap = new HashMap<>(); + public static final Map addressMap = new HashMap<>(); + public static final Map> typeMap = new HashMap<>(); + + + // TODO: BOOL, WORD; DWORD and LWORD are commented because random generation is not working with this types + // or a because a reverse type mapping between avro and PlcTypes is not implemented + public static final RecordSchema schema; + + + static { + List recordFields = new ArrayList<>(); + + // recordFields.add(new RecordField(null, null, recordFields)) + recordFields.add(new RecordField("BOOL", RecordFieldType.BOOLEAN.getDataType(), true)); + // recordFields.add(new RecordField("BYTE", RecordFieldType.SHORT.getDataType(), new byte[] {1,2}) Bytes + // recordFields.add(new RecordField("WORD", "4") String + recordFields.add(new RecordField("SINT", RecordFieldType.SHORT.getDataType(), -5)); + recordFields.add(new RecordField("USINT", RecordFieldType.SHORT.getDataType(), "6")); + recordFields.add(new RecordField("INT", RecordFieldType.ARRAY.getArrayDataType(RecordFieldType.INT.getDataType()))); + recordFields.add(new RecordField("UINT", RecordFieldType.INT.getDataType(), "3000")); + recordFields.add(new RecordField("DINT", RecordFieldType.INT.getDataType(), "4000")); + recordFields.add(new RecordField("UDINT", RecordFieldType.LONG.getDataType(), "5000")); + // recordFields.add(new RecordField("DWORD", RecordFieldType.BOOLEAN.getDataType(), "0")); + // recordFields.add(new RecordField("LI.NT", RecordFieldType.BOOLEAN.getDataType(), 6000L)); + recordFields.add(new RecordField("ULINT", RecordFieldType.BIGINT.getDataType(), "7000")); + // recordFields.add(new RecordField("LWORD", RecordFieldType.BOOLEAN.getDataType(), "0")); + recordFields.add(new RecordField("REAL", RecordFieldType.FLOAT.getDataType(), 1.23456F)); + recordFields.add(new RecordField("LREAL", RecordFieldType.DOUBLE.getDataType(), 2.34567)); + recordFields.add(new RecordField("CHAR", RecordFieldType.STRING.getDataType(), "c")); + recordFields.add(new RecordField("WCHAR", RecordFieldType.STRING.getDataType(), "d")); + recordFields.add(new RecordField("STRING", RecordFieldType.STRING.getDataType(), "this is a string")); + + schema = new SimpleRecordSchema(recordFields); + + + // originalMap values are in the type needed to check type mapping between PlcType and Avro + originalMap.put("BOOL", true); + originalMap.put("BYTE", (byte) 3); + originalMap.put("WORD", "4"); + originalMap.put("SINT", -5); + originalMap.put("USINT", "6"); + originalMap.put("INT", new int[]{2000, 3000, 4000, 5000}); + originalMap.put("UINT", "3000"); + originalMap.put("DINT", "4000"); + originalMap.put("UDINT", "5000"); + originalMap.put("DWORD", Long.valueOf("0")); + originalMap.put("LI.NT", 6000L); + originalMap.put("ULINT", "7000"); + originalMap.put("LWORD", Long.valueOf("0")); + originalMap.put("REAL", 1.23456F); + originalMap.put("LREAL", 2.34567); + originalMap.put("CHAR", "c"); + originalMap.put("WCHAR", "d"); + originalMap.put("STRING", "this is a string"); + + addressMap.put("BOOL", "RANDOM/v1:BOOL"); + addressMap.put("BYTE", "RANDOM/v2:BYTE"); + addressMap.put("WORD", "RANDOM/v3:WORD"); + addressMap.put("SINT", "RANDOM/v4:SINT"); + addressMap.put("USINT", "RANDOM/v5:USINT"); + addressMap.put("INT", "RANDOM/v6:INT"); + addressMap.put("UINT", "RANDOM/v7:UINT"); + addressMap.put("DINT", "RANDOM/v8:DINT"); + addressMap.put("UDINT", "RANDOM/v9:UDINT"); + addressMap.put("DWORD", "RANDOM/v10:DWORD"); + addressMap.put("LI.NT", "RANDOM/v11:LINT"); + addressMap.put("ULINT", "RANDOM/v12:ULINT"); + addressMap.put("LWORD", "RANDOM/v13:LWORD"); + addressMap.put("REAL", "RANDOM/v14:REAL"); + addressMap.put("LREAL", "RANDOM/v15:LREAL"); + addressMap.put("CHAR", "RANDOM/v16:CHAR"); + addressMap.put("WCHAR", "RANDOM/v17:WCHAR"); + addressMap.put("STRING", "RANDOM/v18:STRING"); + + typeMap.put("BOOL", Boolean.class); + typeMap.put("BYTE", Short.class); + typeMap.put("WORD", String.class); + typeMap.put("SINT", Short.class); + typeMap.put("USINT", Short.class); + typeMap.put("INT", Integer.class); + typeMap.put("UINT", Integer.class); + typeMap.put("DINT", Integer.class); + typeMap.put("UDINT", Long.class); + typeMap.put("DWORD", String.class); + typeMap.put("LI.NT", Long.class); + typeMap.put("ULINT", BigInteger.class); + typeMap.put("LWORD", String.class); + typeMap.put("REAL", Float.class); + typeMap.put("LREAL", Double.class); + typeMap.put("CHAR", String.class); + typeMap.put("WCHAR", String.class); + typeMap.put("STRING", String.class); + + } + + public static Map getAddressMap(){ + Map result = new HashMap<>(); + + addressMap.forEach((k,v) -> { + if (v.startsWith("RANDOM/")) { + if (!v.endsWith("BYTE") && + !v.endsWith("CHAR")) + result.put(k, v); + } else { + result.put(k, v); + } + + }); + return result; + } + + public static void assertContent(List flowfiles, boolean checkValue, boolean checkType) { + flowfiles.forEach(t -> { + + try (InputStream stream = new ByteArrayInputStream(t.getContent().getBytes(StandardCharsets.UTF_8))) { + try (JsonTreeRowRecordReader reader = new JsonTreeRowRecordReader(stream, logger, schema, null, null, null)) { + Record record = reader.nextRecord(); + + while (record!=null) { + for (String tag : Plc4xCommonTest.addressMap.keySet()) { + + Object value = record.getValue(tag); + + if (value != null) { + // Check value after string conversion + if (checkValue) { + logger.info("{} Checking type: {} =? {}", tag, value, Plc4xCommonTest.originalMap.get(tag)); + assert value.toString().equalsIgnoreCase(Plc4xCommonTest.originalMap.get(tag).toString()); + } + + // Check type + if (checkType) { + if (!(value instanceof Object[])) { + logger.info("{} Checking type: {} ({}) =? {}", tag, value.getClass(), value, + Plc4xCommonTest.typeMap.get(tag)); + assert value.getClass().equals(Plc4xCommonTest.typeMap.get(tag)); + } else { + logger.info("{} Checking List type: {} ({}) =? {}", tag, + ((Object[]) value)[0].getClass(), value, + Plc4xCommonTest.typeMap.get(tag)); + assert ((Object[]) value)[0].getClass() + .equals(Plc4xCommonTest.typeMap.get(tag)); + } + } + } + } + record = reader.nextRecord(); + } + } catch (IOException | MalformedRecordException e) { + e.printStackTrace(); + } + } catch (IOException e) { + e.printStackTrace();; + } + + }); + } + + public static Record getTestRecord() { + Record record = new MapRecord(schema, originalMap); + return record; + } + + + public static void setLogger(MockComponentLog logg) { + logger = logg; + } +} diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/test/resources/logback-test.xml b/plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/test/resources/logback-test.xml similarity index 100% rename from plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/test/resources/logback-test.xml rename to plc4j/integrations/apache-nifi/nifi-1/nifi-plc4x-processors/src/test/resources/logback-test.xml diff --git a/plc4j/integrations/apache-nifi/nifi-1/pom.xml b/plc4j/integrations/apache-nifi/nifi-1/pom.xml new file mode 100644 index 000000000..c1be40778 --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-1/pom.xml @@ -0,0 +1,125 @@ + + + + + + org.apache.plc4x.extras + plc4j-apache-nifi + 1.0.0-SNAPSHOT + + + plc4j-apache-nifi-1 + pom + + PLC4J: Integrations: Apache Nifi 1 + Integration module for integrating PLC4X into Apache Nifi 1. + + + + 2026-09-20T15:22:10Z + 1.28.1 + 2.3.0 + + + + + + + + org.apache.nifi + nifi-nar-maven-plugin + ${nifi.nar-plugin.version} + true + + + + org.codehaus.mojo + buildnumber-maven-plugin + true + + + validate + + create + + + + + false + false + 7 + true + + buildRevision + buildBranch + + + + + + + + + + + org.junit + junit-bom + 6.1.1 + pom + import + + + net.bytebuddy + byte-buddy + 1.18.11 + + + org.apache.nifi + nifi + ${nifi.version} + pom + import + + + org.apache.nifi + nifi-utils + ${nifi.version} + + + org.apache.nifi + nifi-json-utils + ${nifi.version} + + + org.apache.nifi + nifi-record-serialization-services + ${nifi.version} + + + org.apache.nifi + nifi-nar-bundles + ${nifi.version} + pom + import + + + + + \ No newline at end of file diff --git a/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-nar/bin/.gitignore b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-nar/bin/.gitignore new file mode 100644 index 000000000..26fd4d712 --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-nar/bin/.gitignore @@ -0,0 +1,2 @@ +/false-positives.xml +/pom.xml diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-nar/pom.xml b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-nar/pom.xml similarity index 59% rename from plc4j/integrations/apache-nifi/nifi-plc4x-nar/pom.xml rename to plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-nar/pom.xml index baf43bd9e..335e4fe1a 100644 --- a/plc4j/integrations/apache-nifi/nifi-plc4x-nar/pom.xml +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-nar/pom.xml @@ -17,55 +17,64 @@ specific language governing permissions and limitations under the License. --> - - 4.0.0 + - org.apache.plc4x - plc4j-apache-nifi - 0.13.0-SNAPSHOT + org.apache.plc4x.extras + plc4j-apache-nifi-2 + 1.0.0-SNAPSHOT - plc4j-nifi-plc4x-nar + plc4j-nifi-2-plc4x-nar nar - PLC4J: Integrations: Apache Nifi: NAR - - - 2024-02-16T14:53:02Z - true - true - + PLC4J: Integrations: Apache Nifi 2: NAR - - org.apache.plc4x - plc4j-nifi-plc4x-processors - 0.13.0-SNAPSHOT - org.apache.nifi nifi-standard-services-api-nar ${nifi.version} nar - provided + + org.apache.plc4x.extras + plc4j-nifi-2-plc4x-processors + 1.0.0-SNAPSHOT + + org.apache.plc4x plc4j-api - 0.13.0-SNAPSHOT + ${plc4x.version} org.apache.plc4x plc4j-driver-all - 0.13.0-SNAPSHOT + ${plc4x.version} pom + + + + + + org.apache.nifi + nifi-nar-maven-plugin + + + + org.codehaus.mojo + buildnumber-maven-plugin + + + + \ No newline at end of file diff --git a/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/pom.xml b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/pom.xml new file mode 100644 index 000000000..8ce13cf4e --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/pom.xml @@ -0,0 +1,175 @@ + + + + + + org.apache.plc4x.extras + plc4j-apache-nifi-2 + 1.0.0-SNAPSHOT + + + plc4j-nifi-2-plc4x-processors + + PLC4J: Integrations: Apache Nifi 2: Processors + + + 2026-09-20T15:22:10Z + + + + + org.apache.plc4x + plc4j-api + ${plc4x.version} + + + org.apache.plc4x + plc4j-tools-connection-cache + ${plc4x.version} + + + org.apache.plc4x + plc4j-spi-drivers + ${plc4x.version} + + + + + org.apache.nifi + nifi-api + + + org.apache.nifi + nifi-utils + + + org.apache.nifi + nifi-record + + + org.apache.nifi + nifi-record-serialization-service-api + + + org.apache.nifi + nifi-json-utils + + + + + com.fasterxml.jackson.core + jackson-core + + + com.fasterxml.jackson.core + jackson-databind + + + + + org.apache.nifi + nifi-record-serialization-services + test + + + javax.validation + validation-api + + + + + + org.apache.nifi + nifi-schema-registry-service-api + test + + + org.apache.nifi + nifi-mock + test + + + org.apache.nifi + nifi-mock-record-utils + test + + + + + org.apache.plc4x + plc4j-driver-modbus + ${plc4x.version} + test + + + org.apache.plc4x + plc4j-driver-opcua + ${plc4x.version} + test + + + org.apache.plc4x + plc4j-driver-s7 + ${plc4x.version} + test + + + org.apache.plc4x + plc4j-driver-simulated + ${plc4x.version} + test + + + org.apache.plc4x + plc4j-transports-pcap-replay + ${plc4x.version} + test + + + org.apache.plc4x + plc4j-transports-raw-socket + ${plc4x.version} + test + + + org.apache.plc4x + plc4j-transports-serial + ${plc4x.version} + test + + + org.apache.plc4x + plc4j-transports-tcp + ${plc4x.version} + test + + + org.apache.plc4x + plc4j-transports-udp + ${plc4x.version} + test + + + org.mockito + mockito-core + test + + + \ No newline at end of file diff --git a/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/BasePlc4xProcessor.java b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/BasePlc4xProcessor.java new file mode 100644 index 000000000..2289f690e --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/BasePlc4xProcessor.java @@ -0,0 +1,394 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.nifi; + +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +import org.apache.nifi.annotation.lifecycle.OnScheduled; +import org.apache.nifi.components.PropertyDescriptor; +import org.apache.nifi.components.ValidationContext; +import org.apache.nifi.components.ValidationResult; +import org.apache.nifi.components.Validator; +import org.apache.nifi.expression.ExpressionLanguageScope; +import org.apache.nifi.flowfile.FlowFile; +import org.apache.nifi.logging.ComponentLog; +import org.apache.nifi.processor.AbstractProcessor; +import org.apache.nifi.processor.ProcessContext; +import org.apache.nifi.processor.ProcessSession; +import org.apache.nifi.processor.ProcessorInitializationContext; +import org.apache.nifi.processor.Relationship; +import org.apache.nifi.processor.util.StandardValidators; +import org.apache.nifi.serialization.record.RecordSchema; +import org.apache.plc4x.java.DefaultPlcDriverManager; +import org.apache.plc4x.java.api.PlcConnection; +import org.apache.plc4x.java.api.PlcDriver; +import org.apache.plc4x.java.api.messages.PlcReadRequest; +import org.apache.plc4x.java.api.messages.PlcReadResponse; +import org.apache.plc4x.java.api.messages.PlcWriteRequest; +import org.apache.plc4x.java.api.messages.PlcWriteResponse; +import org.apache.plc4x.java.api.model.PlcTag; +import org.apache.plc4x.java.api.types.PlcResponseCode; +import org.apache.plc4x.java.utils.cache.PlcConnectionCache; +import org.apache.plc4x.nifi.address.AddressesAccessStrategy; +import org.apache.plc4x.nifi.address.AddressesAccessUtils; +import org.apache.plc4x.nifi.address.DynamicPropertyAccessStrategy; +import org.apache.plc4x.nifi.record.Plc4xWriter; +import org.apache.plc4x.nifi.record.SchemaCache; + +public abstract class BasePlc4xProcessor extends AbstractProcessor { + + protected List properties; + protected Set relationships; + protected volatile boolean debugEnabled; + protected Integer cacheSize = 0; + + protected final SchemaCache schemaCache = new SchemaCache(0); + protected AddressesAccessStrategy addressAccessStrategy; + + private PlcConnectionCache connectionCache; + + protected PlcConnectionCache getConnectionCache() { + return connectionCache; + } + + protected void refreshConnectionCache() { + connectionCache = PlcConnectionCache.getBuilder() + .withConnectionFactory(new DefaultPlcDriverManager()) + .withMaxLeaseTime(1000L, TimeUnit.SECONDS) + .withMaxWaitTime(500L, TimeUnit.SECONDS) + .build(); + } + + + public static final PropertyDescriptor PLC_CONNECTION_STRING = new PropertyDescriptor.Builder() + .name("plc4x-connection-string") + .displayName("PLC connection String") + .description("PLC4X connection string used to connect to a given PLC device.") + .required(true) + .expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES) + .addValidator(new Plc4xConnectionStringValidator()) + .build(); + + public static final PropertyDescriptor PLC_SCHEMA_CACHE_SIZE = new PropertyDescriptor.Builder() + .name("plc4x-record-schema-cache-size") + .displayName("Schema Cache Size") + .description("Maximum number of entries in the cache. Can improve performance when addresses change dynamically.") + .defaultValue("1") + .required(true) + .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT) + .addValidator(StandardValidators.POSITIVE_INTEGER_VALIDATOR) + .build(); + + public static final PropertyDescriptor PLC_FUTURE_TIMEOUT_MILISECONDS = new PropertyDescriptor.Builder() + .name("plc4x-request-timeout") + .displayName("Timeout (miliseconds)") + .description( "Request timeout in miliseconds") + .defaultValue("10000") + .required(true) + .expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES) + .addValidator(StandardValidators.POSITIVE_INTEGER_VALIDATOR) + .build(); + + public static final PropertyDescriptor PLC_TIMESTAMP_FIELD_NAME = new PropertyDescriptor.Builder() + .name("plc4x-timestamp-field-name") + .displayName("Timestamp Field Name") + .description("Name of the field that will display the timestamp of the operation.") + .required(true) + .expressionLanguageSupported(ExpressionLanguageScope.ENVIRONMENT) + .addValidator(new Plc4xTimestampFieldValidator()) + .defaultValue("ts") + .build(); + + + protected static final Relationship REL_SUCCESS = new Relationship.Builder() + .name("success") + .description("Successfully processed") + .build(); + + protected static final Relationship REL_FAILURE = new Relationship.Builder() + .name("failure") + .description("An error occurred processing") + .build(); + + + @Override + protected void init(final ProcessorInitializationContext context) { + final List properties = new ArrayList<>(); + + properties.add(PLC_CONNECTION_STRING); + properties.add(AddressesAccessUtils.PLC_ADDRESS_ACCESS_STRATEGY); + properties.add(AddressesAccessUtils.ADDRESS_TEXT_PROPERTY); + properties.add(AddressesAccessUtils.ADDRESS_FILE_PROPERTY); + properties.add(PLC_SCHEMA_CACHE_SIZE); + properties.add(PLC_FUTURE_TIMEOUT_MILISECONDS); + properties.add(PLC_TIMESTAMP_FIELD_NAME); + this.properties = Collections.unmodifiableList(properties); + + + final Set relationships = new HashSet<>(); + relationships.add(REL_SUCCESS); + relationships.add(REL_FAILURE); + this.relationships = Collections.unmodifiableSet(relationships); + } + + public Map getPlcAddressMap(ProcessContext context, FlowFile flowFile) { + return addressAccessStrategy.extractAddresses(context, flowFile); + } + + public String getConnectionString(ProcessContext context, FlowFile flowFile) { + return context.getProperty(PLC_CONNECTION_STRING).evaluateAttributeExpressions(flowFile).getValue(); + } + + public Long getTimeout(ProcessContext context, FlowFile flowFile) { + return context.getProperty(PLC_FUTURE_TIMEOUT_MILISECONDS).evaluateAttributeExpressions(flowFile).asLong(); + } + + public String getTimestampField(ProcessContext context) { + return context.getProperty(PLC_TIMESTAMP_FIELD_NAME).evaluateAttributeExpressions().getValue(); + } + + public SchemaCache getSchemaCache() { + return schemaCache; + } + + @Override + public Set getRelationships() { + return this.relationships; + } + + @Override + public final List getSupportedPropertyDescriptors() { + return properties; + } + + @Override + protected PropertyDescriptor getSupportedDynamicPropertyDescriptor(final String propertyDescriptorName) { + return new PropertyDescriptor.Builder() + .name(propertyDescriptorName) + .expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES) + .addValidator(StandardValidators.ATTRIBUTE_KEY_PROPERTY_NAME_VALIDATOR) + .addValidator(new DynamicPropertyAccessStrategy.TagValidator(AddressesAccessUtils.getManager())) + .required(false) + .dynamic(true) + .build(); + } + + + @OnScheduled + public void onScheduled(final ProcessContext context) { + Integer newCacheSize = context.getProperty(PLC_SCHEMA_CACHE_SIZE).evaluateAttributeExpressions().asInteger(); + if (!newCacheSize.equals(cacheSize)){ + schemaCache.restartCache(newCacheSize); + cacheSize = newCacheSize; + } + refreshConnectionCache(); + debugEnabled = getLogger().isDebugEnabled(); + addressAccessStrategy = AddressesAccessUtils.getAccessStrategy(context); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof BasePlc4xProcessor)) { + return false; + } + if (!super.equals(o)) { + return false; + } + BasePlc4xProcessor that = (BasePlc4xProcessor) o; + return Objects.equals(properties, that.properties) && + Objects.equals(getRelationships(), that.getRelationships()); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), properties, getRelationships()); + } + + protected PlcWriteRequest getWriteRequest(final ComponentLog logger, + final Map addressMap, final Map tags, final Map presentTags, + final PlcConnection connection, final AtomicLong nrOfRowsHere) { + + PlcWriteRequest.Builder builder = connection.writeRequestBuilder(); + + if (tags != null){ + for (Map.Entry tag : tags.entrySet()){ + if (presentTags.containsKey(tag.getKey())) { + builder.addTag(tag.getKey(), tag.getValue(), presentTags.get(tag.getKey())); + if (nrOfRowsHere != null) { + nrOfRowsHere.incrementAndGet(); + } + } else { + if (debugEnabled) + logger.debug("PlcTag " + tag + " is declared as address but was not found on input record."); + } + } + } else { + if (debugEnabled) + logger.debug("PlcTypes resolution not found in cache and will be added with key: " + addressMap); + for (Map.Entry entry: addressMap.entrySet()){ + if (presentTags.containsKey(entry.getKey())) { + builder.addTagAddress(entry.getKey(), entry.getValue(), presentTags.get(entry.getKey())); + if (nrOfRowsHere != null) { + nrOfRowsHere.incrementAndGet(); + } + } + } + } + + return builder.build(); + } + + protected PlcReadRequest getReadRequest(final ComponentLog logger, + final Map addressMap, final Map tags, + final PlcConnection connection) { + + PlcReadRequest.Builder builder = connection.readRequestBuilder(); + + if (tags != null){ + for (Map.Entry tag : tags.entrySet()){ + builder.addTag(tag.getKey(), tag.getValue()); + } + } else { + if (debugEnabled) + logger.debug("Schema and PlcTypes resolution not found in cache and will be added with key: " + addressMap); + for (Map.Entry entry: addressMap.entrySet()){ + builder.addTagAddress(entry.getKey(), entry.getValue()); + } + } + return builder.build(); + } + + protected void evaluateWriteResponse(final ComponentLog logger, Map values, PlcWriteResponse plcWriteResponse) { + + boolean codeErrorPresent = false; + List tagsAtError = null; + + PlcResponseCode code; + + for (String tag : plcWriteResponse.getTagNames()) { + code = plcWriteResponse.getResponseCode(tag); + if (!code.equals(PlcResponseCode.OK)) { + if (tagsAtError == null) { + tagsAtError = new ArrayList<>(); + } + logger.warn("Not OK code when writing the data to PLC for tag {} with value {}", tag, values.get(tag)); + + codeErrorPresent = true; + tagsAtError.add(tag); + + } + } + if (codeErrorPresent) { + logger.warn("At least one error was found when while writting tags: {}" + tagsAtError); + } + } + + protected void evaluateReadResponse(final ProcessSession session, final FlowFile flowFile, final PlcReadResponse response) { + Map attributes = new HashMap<>(); + for (String tagName : response.getTagNames()) { + for (int i = 0; i < response.getNumberOfValues(tagName); i++) { + Object value = response.getObject(tagName, i); + attributes.put(tagName, String.valueOf(value)); + } + } + session.putAllAttributes(flowFile, attributes); + } + + protected long evaluateReadResponse(final ProcessContext context, final ComponentLog logger, final FlowFile originalFlowFile, + Plc4xWriter plc4xWriter, OutputStream out, final RecordSchema recordSchema, PlcReadResponse readResponse) + throws Exception { + + if(originalFlowFile == null) //there is no inherit attributes to use in writer service + return plc4xWriter.writePlcReadResponse(readResponse, out, logger, recordSchema, getTimestampField(context)); + else + return plc4xWriter.writePlcReadResponse(readResponse, out, logger, recordSchema, originalFlowFile, getTimestampField(context)); + } + + protected static class Plc4xConnectionStringValidator implements Validator { + @Override + public ValidationResult validate(String subject, String input, ValidationContext context) { + + if (context.isExpressionLanguageSupported(subject) && context.isExpressionLanguagePresent(input)) { + return new ValidationResult.Builder().subject(subject).input(input).explanation("Expression Language Present").valid(true).build(); + } + // Validation opens a real connection, so it has to close it again: NiFi validates + // properties repeatedly and every leaked connection leaves a session behind on the + // device until it times out. + try { + PlcDriver driver = AddressesAccessUtils.getManager().getDriverForUrl(input); + try (PlcConnection connection = driver.getConnection(input)) { + // Opening and closing is the check; nothing to do with the connection itself. + } + } catch (Exception e) { + // Not just PlcConnectionException: a driver may reject a connection string with an + // unchecked exception (the OPC UA driver throws PlcRuntimeException when no + // endpoint matches, for instance). Letting that escape turns a property the user + // can fix into a failing validator. + String explanation = e.getMessage() == null ? e.getClass().getName() : e.getMessage(); + return new ValidationResult.Builder().subject(subject) + .input(input) + .explanation(explanation) + .valid(false) + .build(); + } + return new ValidationResult.Builder().subject(subject) + .explanation("") + .valid(true) + .build(); + } + } + + protected static class Plc4xTimestampFieldValidator implements Validator { + @Override + public ValidationResult validate(String subject, String input, ValidationContext context) { + + if (context.isExpressionLanguageSupported(subject) && context.isExpressionLanguagePresent(input)) { + return new ValidationResult.Builder().subject(subject).input(input).explanation("Expression Language Present").valid(true).build(); + } + + Map allProperties = context.getAllProperties(); + allProperties.remove(subject); + + if (allProperties.containsValue(input)) { + return new ValidationResult.Builder().subject(subject) + .explanation("Timestamp field must be unique") + .valid(false) + .build(); + } + return new ValidationResult.Builder().subject(subject) + .explanation("") + .valid(true) + .build(); + + } + } +} diff --git a/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xListenRecordProcessor.java b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xListenRecordProcessor.java new file mode 100644 index 000000000..37ca988fe --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xListenRecordProcessor.java @@ -0,0 +1,308 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you 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. + */ +package org.apache.plc4x.nifi; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +import org.apache.nifi.annotation.behavior.InputRequirement; +import org.apache.nifi.annotation.behavior.WritesAttribute; +import org.apache.nifi.annotation.behavior.WritesAttributes; +import org.apache.nifi.annotation.configuration.DefaultSchedule; +import org.apache.nifi.annotation.documentation.CapabilityDescription; +import org.apache.nifi.annotation.documentation.SeeAlso; +import org.apache.nifi.annotation.documentation.Tags; +import org.apache.nifi.annotation.lifecycle.OnScheduled; +import org.apache.nifi.annotation.lifecycle.OnStopped; +import org.apache.nifi.components.PropertyDescriptor; +import org.apache.nifi.components.ValidationContext; +import org.apache.nifi.components.ValidationResult; +import org.apache.nifi.components.Validator; +import org.apache.nifi.flowfile.FlowFile; +import org.apache.nifi.processor.ProcessContext; +import org.apache.nifi.processor.ProcessSession; +import org.apache.nifi.processor.ProcessorInitializationContext; +import org.apache.nifi.processor.Relationship; +import org.apache.nifi.processor.exception.ProcessException; +import org.apache.nifi.processor.util.StandardValidators; +import org.apache.nifi.serialization.RecordSetWriterFactory; +import org.apache.nifi.serialization.record.RecordSchema; +import org.apache.nifi.util.StopWatch; +import org.apache.plc4x.java.api.messages.PlcSubscriptionEvent; +import org.apache.plc4x.java.api.model.PlcTag; +import org.apache.plc4x.java.api.types.PlcValueType; +import org.apache.plc4x.java.spi.drivers.messages.DefaultPlcSubscriptionEvent; +import org.apache.plc4x.nifi.record.Plc4xWriter; +import org.apache.plc4x.nifi.record.RecordPlc4xWriter; +import org.apache.plc4x.nifi.subscription.Plc4xListenerDispatcher; +import org.apache.plc4x.nifi.subscription.Plc4xSubscriptionType; + +@DefaultSchedule(period="0.1 sec") +@Tags({"plc4x", "get", "input", "source", "listen", "record"}) +@SeeAlso({Plc4xSourceRecordProcessor.class, Plc4xSinkRecordProcessor.class}) +@InputRequirement(InputRequirement.Requirement.INPUT_FORBIDDEN) +@CapabilityDescription("Processor able to read data from industrial PLCs using Apache PLC4X subscriptions") +@WritesAttributes({ + @WritesAttribute(attribute = Plc4xListenRecordProcessor.RESULT_ROW_COUNT, description = "Number of rows written into the output FlowFile"), + @WritesAttribute(attribute = Plc4xListenRecordProcessor.RESULT_LAST_EVENT, description = "Time elapsed from last subscription event") + }) +public class Plc4xListenRecordProcessor extends BasePlc4xProcessor { + + public static final String RESULT_ROW_COUNT = "plc4x.listen.row.count"; + public static final String RESULT_LAST_EVENT = "plc4x.listen.lastEvent"; + + protected Plc4xSubscriptionType subscriptionType = null; + protected Long cyclingPollingInterval = null; + protected final BlockingQueue events = new LinkedBlockingQueue<>(); + protected Plc4xListenerDispatcher dispatcher; + protected RecordSchema recordSchema; + protected Thread readerThread; + protected Map addressMap; + final StopWatch executeTime = new StopWatch(false); + + public static final PropertyDescriptor PLC_RECORD_WRITER_FACTORY = new PropertyDescriptor.Builder() + .name("plc4x-record-writer") + .displayName("Record Writer") + .description("Specifies the Controller Service to use for writing results to a FlowFile. The Record Writer may use Inherit Schema to emulate the inferred schema behavior, i.e. " + + "an explicit schema need not be defined in the writer, and will be supplied by the same logic used to infer the schema from the column types.") + .identifiesControllerService(RecordSetWriterFactory.class) + .required(true) + .build(); + + public static final PropertyDescriptor PLC_SUBSCRIPTION_TYPE = new PropertyDescriptor.Builder() + .name("plc4x-subscription-type") + .displayName("Subscription Type") + .description("Sets the subscription type. The subscritpion types available for each driver are stated in the documentation.") + .allowableValues(Plc4xSubscriptionType.values()) + .required(true) + .defaultValue(Plc4xSubscriptionType.CHANGE.name()) + .build(); + + public static final PropertyDescriptor PLC_SUBSCRIPTION_CYCLIC_POLLING_INTERVAL = new PropertyDescriptor.Builder() + .name("plc4x-subscription-cyclic-polling-interval") + .displayName("Cyclic polling interval") + .description("In case of Cyclic subscription type a time interval must be provided.") + .dependsOn(PLC_SUBSCRIPTION_TYPE, Plc4xSubscriptionType.CYCLIC.name()) + .required(true) + .addValidator(StandardValidators.POSITIVE_INTEGER_VALIDATOR) + .addValidator(new CyclycPollingIntervalValidator()) + .defaultValue("10000") + .build(); + + @Override + protected void init(final ProcessorInitializationContext context) { + super.init(context); + final Set relationships = new HashSet<>(); + relationships.add(REL_SUCCESS); + this.relationships = Collections.unmodifiableSet(relationships); + + final List pds = new ArrayList<>(super.getSupportedPropertyDescriptors()); + pds.add(PLC_RECORD_WRITER_FACTORY); + pds.add(PLC_SUBSCRIPTION_TYPE); + pds.add(PLC_SUBSCRIPTION_CYCLIC_POLLING_INTERVAL); + this.properties = Collections.unmodifiableList(pds); + } + + @Override + @OnScheduled + public void onScheduled(final ProcessContext context) { + super.onScheduled(context); + subscriptionType = Plc4xSubscriptionType.valueOf(context.getProperty(PLC_SUBSCRIPTION_TYPE).getValue()); + if (subscriptionType.equals(Plc4xSubscriptionType.CYCLIC)){ + // Only access the property if the dependency is fullfilled + cyclingPollingInterval = context.getProperty(PLC_SUBSCRIPTION_CYCLIC_POLLING_INTERVAL).asLong(); + } + createDispatcher(context, events); + } + + protected void createDispatcher(final ProcessContext context, final BlockingQueue events) { + if (readerThread != null) { + return; + } + + // create the dispatcher and calls open() to start listening to the plc subscription + dispatcher = new Plc4xListenerDispatcher(getTimeout(context, null), subscriptionType, cyclingPollingInterval, getLogger(), events); + try { + addressMap = getPlcAddressMap(context, null); + dispatcher.open(getConnectionString(context, null), addressMap); + } catch (Exception e) { + if (debugEnabled) { + getLogger().debug("Error creating a the subscription event dispatcher"); + } + throw new ProcessException(e); + } + + if (dispatcher.isRunning()) { + readerThread = new Thread(dispatcher); + readerThread.setName(getClass().getName() + " [" + getIdentifier() + "]"); + readerThread.setDaemon(true); + readerThread.start(); + } + executeTime.start(); + } + + @OnStopped + public void closeDispatcher() throws ProcessException { + executeTime.stop(); + if (readerThread != null) { + readerThread.interrupt(); + try { + readerThread.join(); + } catch (InterruptedException e) { + throw new ProcessException(e); + } + if (!readerThread.isAlive()){ + readerThread = null; + } + } + } + + protected PlcSubscriptionEvent getMessage(final ProcessContext context) { + if (readerThread != null && readerThread.isAlive()) { + return events.poll(); + + } + // If dispatcher is not running the connection broke or gave a time out. + if (debugEnabled) { + getLogger().debug("Connection to Plc broke. Trying to restart connection"); + } + closeDispatcher(); + createDispatcher(context, events); + throw new ProcessException("Connection to Plc broke. Trying to restart connection"); + } + + @Override + public void onTrigger(final ProcessContext context, final ProcessSession session) throws ProcessException { + + DefaultPlcSubscriptionEvent event = (DefaultPlcSubscriptionEvent) getMessage(context); + + if (event == null) { + return; + } else { + session.adjustCounter("Messages Received", 1L, false); + } + + + final AtomicLong nrOfRows = new AtomicLong(0L); + + FlowFile resultSetFF = session.create(); + + Plc4xWriter plc4xWriter = new RecordPlc4xWriter(context.getProperty(PLC_RECORD_WRITER_FACTORY).asControllerService(RecordSetWriterFactory.class), Collections.emptyMap()); + + try { + session.write(resultSetFF, out -> { + try { + nrOfRows.set(plc4xWriter.writePlcReadResponse(event, out, getLogger(), recordSchema, getTimestampField(context))); + } catch (Exception e) { + getLogger().error("Exception reading the data from PLC", e); + throw (e instanceof ProcessException) ? (ProcessException) e : new ProcessException(e); + } + + if (recordSchema == null){ + addTagsToCache(event, plc4xWriter); + } + }); + resultSetFF = completeResultFlowFile(session, nrOfRows, resultSetFF, plc4xWriter); + session.transfer(resultSetFF, REL_SUCCESS); + + executeTime.start(); + + } catch (Exception e) { + getLogger().error("Got an error while trying to get a subscription event", e); + throw new ProcessException("Got an error while trying to get a subscription event", e); + } + } + + private void addTagsToCache(DefaultPlcSubscriptionEvent event, Plc4xWriter plc4xWriter) { + if (debugEnabled) + getLogger().debug("Adding Schema and PlcTypes resolution into cache with key: " + addressMap.toString()); + + // Add schema to the cache + LinkedHashSet addressNames = new LinkedHashSet<>(event.getTagNames()); + + List addressTags = addressNames.stream().map(addr -> + new PlcTag() { + @Override + public String getAddressString() { + return addr; + } + + @Override + public PlcValueType getPlcValueType() { + return event.getPlcValue(addr).getPlcValueType(); + } + } + ).collect(Collectors.toList()); + + getSchemaCache().addSchema( + addressMap, + addressNames, + addressTags, + plc4xWriter.getRecordSchema() + ); + recordSchema = getSchemaCache().retrieveSchema(addressMap); + } + + private FlowFile completeResultFlowFile(final ProcessSession session, final AtomicLong nrOfRows, FlowFile resultSetFF, + Plc4xWriter plc4xWriter) { + + long executionTimeElapsed = executeTime.getElapsed(TimeUnit.MILLISECONDS); + executeTime.stop(); + + final Map attributesToAdd = new HashMap<>(); + attributesToAdd.put(RESULT_ROW_COUNT, String.valueOf(nrOfRows.get())); + attributesToAdd.put(RESULT_LAST_EVENT, String.valueOf(executionTimeElapsed)); + + attributesToAdd.putAll(plc4xWriter.getAttributesToAdd()); + resultSetFF = session.putAllAttributes(resultSetFF, attributesToAdd); + plc4xWriter.updateCounters(session); + getLogger().info("{} contains {} records; transferring to 'success'", resultSetFF, nrOfRows.get()); + + session.getProvenanceReporter().receive(resultSetFF, "Retrieved " + nrOfRows.get() + " rows from subscription", executionTimeElapsed); + return resultSetFF; + } + + + protected static class CyclycPollingIntervalValidator implements Validator { + @Override + public ValidationResult validate(String subject, String input, ValidationContext context) { + if (context.getProperty(PLC_FUTURE_TIMEOUT_MILISECONDS).asLong() > Long.parseLong(input)) { + return new ValidationResult.Builder().valid(true).build(); + } else { + return new ValidationResult.Builder() + .valid(false) + .input(input) + .subject(PLC_SUBSCRIPTION_CYCLIC_POLLING_INTERVAL.getDisplayName()) + .explanation(String.format("it must me smaller than the value of %s", PLC_FUTURE_TIMEOUT_MILISECONDS.getDisplayName())) + .build(); + } + } + } +} diff --git a/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSinkProcessor.java b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSinkProcessor.java new file mode 100644 index 000000000..c9ebf9a66 --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSinkProcessor.java @@ -0,0 +1,124 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.nifi; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.apache.nifi.annotation.behavior.InputRequirement; +import org.apache.nifi.annotation.behavior.ReadsAttribute; +import org.apache.nifi.annotation.behavior.ReadsAttributes; +import org.apache.nifi.annotation.behavior.TriggerSerially; +import org.apache.nifi.annotation.documentation.CapabilityDescription; +import org.apache.nifi.annotation.documentation.DeprecationNotice; +import org.apache.nifi.annotation.documentation.SeeAlso; +import org.apache.nifi.annotation.documentation.Tags; +import org.apache.nifi.flowfile.FlowFile; +import org.apache.nifi.logging.ComponentLog; +import org.apache.nifi.processor.ProcessContext; +import org.apache.nifi.processor.ProcessSession; +import org.apache.nifi.processor.exception.ProcessException; +import org.apache.plc4x.java.api.PlcConnection; +import org.apache.plc4x.java.api.messages.PlcWriteRequest; +import org.apache.plc4x.java.api.messages.PlcWriteResponse; +import org.apache.plc4x.java.api.model.PlcTag; +@Deprecated +@TriggerSerially +@Tags({"plc4x", "put", "sink"}) +@SeeAlso({Plc4xSourceProcessor.class}) +@InputRequirement(InputRequirement.Requirement.INPUT_REQUIRED) +@CapabilityDescription("Processor able to write data to industrial PLCs using Apache PLC4X") +@ReadsAttributes({@ReadsAttribute(attribute="value", description="some value")}) +@DeprecationNotice( + reason = "Has been superseded by record processors and will be removed in a future release ", + alternatives = {Plc4xSinkRecordProcessor.class}, + classNames = {"org.apache.plc4x.nifi.Plc4xSinkProcessor"} +) +public class Plc4xSinkProcessor extends BasePlc4xProcessor { + + public static final String EXCEPTION = "plc4x.write.exception"; + + @Override + public void onTrigger(final ProcessContext context, final ProcessSession session) throws ProcessException { + FlowFile flowFile = session.get(); + + // Abort if there's nothing to do. + if (flowFile == null) { + return; + } + + final ComponentLog logger = getLogger(); + + try(PlcConnection connection = getConnectionCache().getConnection(getConnectionString(context, flowFile))) { + if (!connection.getMetadata().isWriteSupported()) { + throw new ProcessException("Writing not supported by connection"); + } + + final Map addressMap = getPlcAddressMap(context, flowFile); + final Map tags = getSchemaCache().retrieveTags(addressMap); + + PlcWriteRequest writeRequest = getWriteRequest(logger, addressMap, tags, flowFile.getAttributes(), connection, null); + + try { + final PlcWriteResponse plcWriteResponse = writeRequest.execute().get(getTimeout(context, flowFile), TimeUnit.MILLISECONDS); + + evaluateWriteResponse(logger, flowFile.getAttributes(), plcWriteResponse); + + // Store all configured tags in cache if not present + if (tags == null) { + if (debugEnabled) + logger.debug("Adding PlcTypes resolution into cache with key: " + addressMap); + + // Parse plc addresses + Map validAddressesPlcTags = new LinkedHashMap<>(); + for (Map.Entry entry : addressMap.entrySet()) { + Optional newTag = connection.parseTagAddress(entry.getValue()); + newTag.ifPresent(parsed -> validAddressesPlcTags.put(entry.getKey(), parsed)); + } + + getSchemaCache().addSchema( + addressMap, + validAddressesPlcTags.keySet(), + new ArrayList<>(validAddressesPlcTags.values()), + null + ); + } + } catch (TimeoutException e) { + logger.error("Timeout writing the data to the PLC", e); + getConnectionCache().removeCachedConnection(getConnectionString(context, flowFile)); + throw new ProcessException(e); + } catch (Exception e) { + logger.error("Exception writing the data to the PLC", e); + throw (e instanceof ProcessException) ? (ProcessException) e : new ProcessException(e); + } + + session.transfer(flowFile, REL_SUCCESS); + } catch (Exception e) { + flowFile = session.putAttribute(flowFile, EXCEPTION, e.getLocalizedMessage()); + session.transfer(flowFile, REL_FAILURE); + session.commitAsync(); + throw (e instanceof ProcessException) ? (ProcessException) e : new ProcessException(e); + } + } + +} diff --git a/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSinkRecordProcessor.java b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSinkRecordProcessor.java new file mode 100644 index 000000000..2ce2b9f67 --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSinkRecordProcessor.java @@ -0,0 +1,204 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you 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. + */ +package org.apache.plc4x.nifi; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicLong; + +import org.apache.nifi.annotation.behavior.InputRequirement; +import org.apache.nifi.annotation.behavior.ReadsAttribute; +import org.apache.nifi.annotation.behavior.ReadsAttributes; +import org.apache.nifi.annotation.behavior.TriggerSerially; +import org.apache.nifi.annotation.behavior.WritesAttribute; +import org.apache.nifi.annotation.behavior.WritesAttributes; +import org.apache.nifi.annotation.documentation.CapabilityDescription; +import org.apache.nifi.annotation.documentation.SeeAlso; +import org.apache.nifi.annotation.documentation.Tags; +import org.apache.nifi.components.PropertyDescriptor; +import org.apache.nifi.flowfile.FlowFile; +import org.apache.nifi.flowfile.attributes.CoreAttributes; +import org.apache.nifi.logging.ComponentLog; +import org.apache.nifi.processor.ProcessContext; +import org.apache.nifi.processor.ProcessSession; +import org.apache.nifi.processor.ProcessorInitializationContext; +import org.apache.nifi.processor.Relationship; +import org.apache.nifi.processor.exception.ProcessException; +import org.apache.nifi.serialization.RecordReader; +import org.apache.nifi.serialization.RecordReaderFactory; +import org.apache.nifi.serialization.record.Record; +import org.apache.nifi.util.StopWatch; +import org.apache.plc4x.java.api.PlcConnection; +import org.apache.plc4x.java.api.exceptions.PlcConnectionException; +import org.apache.plc4x.java.api.messages.PlcWriteRequest; +import org.apache.plc4x.java.api.messages.PlcWriteResponse; +import org.apache.plc4x.java.api.model.PlcTag; + +@TriggerSerially +@Tags({"plc4x", "put", "sink", "record"}) +@SeeAlso({Plc4xSourceRecordProcessor.class, Plc4xListenRecordProcessor.class}) +@InputRequirement(InputRequirement.Requirement.INPUT_REQUIRED) +@CapabilityDescription("Processor able to write data to industrial PLCs using Apache PLC4X") +@ReadsAttributes({@ReadsAttribute(attribute="value", description="some value")}) +@WritesAttributes({ + @WritesAttribute(attribute = Plc4xSinkRecordProcessor.RESULT_ROW_COUNT, description = "Number of rows from the input FlowFile written into the PLC"), + @WritesAttribute(attribute = Plc4xSinkRecordProcessor.RESULT_QUERY_EXECUTION_TIME, description = "Time between request and response from the PLC"), + @WritesAttribute(attribute = Plc4xSinkRecordProcessor.INPUT_FLOWFILE_UUID, description = "UUID of the input FlowFile") + }) +public class Plc4xSinkRecordProcessor extends BasePlc4xProcessor { + + public static final String RESULT_ROW_COUNT = "plc4x.write.row.count"; + public static final String RESULT_QUERY_EXECUTION_TIME = "plc4x.write.query.executiontime"; + public static final String INPUT_FLOWFILE_UUID = "input.flowfile.uuid"; + public static final String EXCEPTION = "plc4x.write.exception"; + + public static final PropertyDescriptor PLC_RECORD_READER_FACTORY = new PropertyDescriptor.Builder() + .name("record-reader").displayName("Record Reader") + .description( + "Specifies the Controller Service to use for reading record from a FlowFile. The Record Reader may use Inherit Schema to emulate the inferred schema behavior, i.e. " + + "an explicit schema need not be defined in the reader, and will be supplied by the same logic used to infer the schema from the column types.") + .identifiesControllerService(RecordReaderFactory.class) + .required(true) + .build(); + + + @Override + protected void init(final ProcessorInitializationContext context) { + super.init(context); + final Set r = new HashSet<>(super.getRelationships()); + this.relationships = Collections.unmodifiableSet(r); + + final List pds = new ArrayList<>(super.getSupportedPropertyDescriptors()); + pds.add(PLC_RECORD_READER_FACTORY); + this.properties = Collections.unmodifiableList(pds); + } + + @Override + public void onTrigger(final ProcessContext context, final ProcessSession session) throws ProcessException { + FlowFile fileToProcess = session.get(); + + // Abort if there's nothing to do. + if (fileToProcess == null) { + return; + } + + final ComponentLog logger = getLogger(); + + // Get an instance of a component able to read from a PLC. + final AtomicLong nrOfRows = new AtomicLong(0L); + final StopWatch executeTime = new StopWatch(true); + + try { + session.read(fileToProcess, in -> { + Record record = null; + + try (RecordReader recordReader = context.getProperty(PLC_RECORD_READER_FACTORY) + .asControllerService(RecordReaderFactory.class) + .createRecordReader(fileToProcess, in, logger)){ + + while ((record = recordReader.nextRecord()) != null) { + AtomicLong nrOfRowsHere = new AtomicLong(0); + PlcWriteRequest writeRequest; + + final Map addressMap = getPlcAddressMap(context, fileToProcess); + final Map tags = getSchemaCache().retrieveTags(addressMap); + + try (PlcConnection connection = getConnectionCache().getConnection(getConnectionString(context, fileToProcess))) { + + writeRequest = getWriteRequest(logger, addressMap, tags, record.toMap(), connection, nrOfRowsHere); + + PlcWriteResponse plcWriteResponse = writeRequest.execute().get(getTimeout(context, fileToProcess), TimeUnit.MILLISECONDS); + + // Response check if values were written + evaluateWriteResponse(logger, record.toMap(), plcWriteResponse); + + // Store all configured tags in cache if not present + if (tags == null) { + if (debugEnabled) + logger.debug("Adding PlcTypes resolution into cache with key: " + addressMap); + + // Parse plc addresses + Map validAddressesPlcTags = new LinkedHashMap<>(); + for (Map.Entry entry : addressMap.entrySet()) { + Optional newTag = connection.parseTagAddress(entry.getValue()); + newTag.ifPresent(parsed -> validAddressesPlcTags.put(entry.getKey(), parsed)); + } + + getSchemaCache().addSchema( + addressMap, + validAddressesPlcTags.keySet(), + new ArrayList<>(validAddressesPlcTags.values()), + null + ); + } + } catch (TimeoutException e) { + logger.error("Timeout writting the data to the PLC", e); + getConnectionCache().removeCachedConnection(getConnectionString(context, fileToProcess)); + throw new ProcessException(e); + } catch (PlcConnectionException e) { + logger.error("Error getting the PLC connection", e); + throw new ProcessException("Got an a PlcConnectionException while trying to get a connection", e); + } catch (Exception e) { + logger.error("Exception writting the data to the PLC", e); + throw (e instanceof ProcessException) ? (ProcessException) e : new ProcessException(e); + } + + nrOfRows.getAndAdd(nrOfRowsHere.get()); + } + } catch (Exception e) { + throw (e instanceof ProcessException) ? (ProcessException) e : new ProcessException(e); + } + }); + + } catch (ProcessException e) { + logger.error("Exception writing the data to the PLC", e); + session.putAttribute(fileToProcess, EXCEPTION, e.getLocalizedMessage()); + session.transfer(fileToProcess, REL_FAILURE); + session.commitAsync(); + throw e; + } + + + long executionTimeElapsed = executeTime.getElapsed(TimeUnit.MILLISECONDS); + final Map attributesToAdd = new HashMap<>(); + attributesToAdd.put(RESULT_ROW_COUNT, String.valueOf(nrOfRows.get())); + attributesToAdd.put(RESULT_QUERY_EXECUTION_TIME, String.valueOf(executionTimeElapsed)); + attributesToAdd.put(INPUT_FLOWFILE_UUID, fileToProcess.getAttribute(CoreAttributes.UUID.key())); + + session.putAllAttributes(fileToProcess, attributesToAdd); + + session.transfer(fileToProcess, REL_SUCCESS); + + logger.info("Writing {} fields from {} records; transferring to 'success'", nrOfRows.get(), fileToProcess); + if (context.hasIncomingConnection()) { + session.getProvenanceReporter().fetch(fileToProcess, "Writted " + nrOfRows.get() + " rows", executionTimeElapsed); + } else { + session.getProvenanceReporter().receive(fileToProcess, "Writted " + nrOfRows.get() + " rows", executionTimeElapsed); + } + } +} diff --git a/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSourceProcessor.java b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSourceProcessor.java new file mode 100644 index 000000000..f268469c1 --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSourceProcessor.java @@ -0,0 +1,130 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.nifi; + +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.apache.nifi.annotation.behavior.InputRequirement; +import org.apache.nifi.annotation.behavior.WritesAttribute; +import org.apache.nifi.annotation.behavior.WritesAttributes; +import org.apache.nifi.annotation.documentation.CapabilityDescription; +import org.apache.nifi.annotation.documentation.DeprecationNotice; +import org.apache.nifi.annotation.documentation.SeeAlso; +import org.apache.nifi.annotation.documentation.Tags; +import org.apache.nifi.flowfile.FlowFile; +import org.apache.nifi.logging.ComponentLog; +import org.apache.nifi.processor.ProcessContext; +import org.apache.nifi.processor.ProcessSession; +import org.apache.nifi.processor.exception.ProcessException; +import org.apache.plc4x.java.api.PlcConnection; +import org.apache.plc4x.java.api.messages.PlcReadRequest; +import org.apache.plc4x.java.api.messages.PlcReadResponse; +import org.apache.plc4x.java.api.model.PlcTag; +@Deprecated +@Tags({"plc4x", "get", "input", "source", "attributes"}) +@SeeAlso({Plc4xSinkProcessor.class}) +@InputRequirement(InputRequirement.Requirement.INPUT_FORBIDDEN) +@CapabilityDescription("Processor able to read data from industrial PLCs using Apache PLC4X") +@WritesAttributes({@WritesAttribute(attribute="value", description="some value")}) +@DeprecationNotice( + reason = "Has been superseded by record processors and will be removed in a future release", + alternatives = {Plc4xSourceRecordProcessor.class}, + classNames = {"org.apache.plc4x.nifi.Plc4xSourceProcessor"} +) +public class Plc4xSourceProcessor extends BasePlc4xProcessor { + + public static final String EXCEPTION = "plc4x.read.exception"; + + @Override + public void onTrigger(final ProcessContext context, final ProcessSession session) throws ProcessException { + + FlowFile incomingFlowFile = null; + if (context.hasIncomingConnection()) { + incomingFlowFile = session.get(); + if (incomingFlowFile == null && context.hasNonLoopConnection()) { + return; + } + } + + final ComponentLog logger = getLogger(); + final FlowFile flowFile = session.create(); + + final String connectionString = getConnectionString(context, incomingFlowFile); + + if (debugEnabled) { + logger.debug("Get connection for plc: {}", connectionString); + } + + try(PlcConnection connection = getConnectionCache().getConnection(connectionString)) { + + if (!connection.getMetadata().isReadSupported()) { + throw new ProcessException("Reading not supported by connection"); + } + + final Map addressMap = getPlcAddressMap(context, incomingFlowFile); + final Map tags = getSchemaCache().retrieveTags(addressMap); + + + PlcReadRequest readRequest = getReadRequest(logger, addressMap, tags, connection); + + try { + final PlcReadResponse response = readRequest.execute().get(getTimeout(context, incomingFlowFile), TimeUnit.MILLISECONDS); + + evaluateReadResponse(session, flowFile, response); + + } catch (TimeoutException e) { + logger.error("Timeout reading the data from PLC", e); + getConnectionCache().removeCachedConnection(getConnectionString(context, incomingFlowFile)); + throw new ProcessException(e); + } catch (Exception e) { + logger.error("Exception reading the data from PLC", e); + throw (e instanceof ProcessException) ? (ProcessException) e : new ProcessException(e); + } + + + if (incomingFlowFile != null) { + session.remove(incomingFlowFile); + } + session.transfer(flowFile, REL_SUCCESS); + + if (tags == null){ + if (debugEnabled) + logger.debug("Adding PlcTypes resolution into cache with key: " + addressMap); + getSchemaCache().addSchema( + addressMap, + readRequest.getTagNames(), + readRequest.getTags(), + null + ); + } + + } catch (Exception e) { + session.remove(flowFile); + if (incomingFlowFile != null){ + incomingFlowFile = session.putAttribute(incomingFlowFile, EXCEPTION, e.getLocalizedMessage()); + session.transfer(incomingFlowFile, REL_FAILURE); + } + session.commitAsync(); + throw (e instanceof ProcessException) ? (ProcessException) e : new ProcessException(e); + } + } + +} diff --git a/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSourceRecordProcessor.java b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSourceRecordProcessor.java new file mode 100644 index 000000000..40ccd32cf --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/Plc4xSourceRecordProcessor.java @@ -0,0 +1,209 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you 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. + */ +package org.apache.plc4x.nifi; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicLong; + +import org.apache.nifi.annotation.behavior.InputRequirement; +import org.apache.nifi.annotation.behavior.WritesAttribute; +import org.apache.nifi.annotation.behavior.WritesAttributes; +import org.apache.nifi.annotation.documentation.CapabilityDescription; +import org.apache.nifi.annotation.documentation.SeeAlso; +import org.apache.nifi.annotation.documentation.Tags; +import org.apache.nifi.components.PropertyDescriptor; +import org.apache.nifi.flowfile.FlowFile; +import org.apache.nifi.flowfile.attributes.CoreAttributes; +import org.apache.nifi.logging.ComponentLog; +import org.apache.nifi.processor.ProcessContext; +import org.apache.nifi.processor.ProcessSession; +import org.apache.nifi.processor.ProcessorInitializationContext; +import org.apache.nifi.processor.Relationship; +import org.apache.nifi.processor.exception.ProcessException; +import org.apache.nifi.serialization.RecordSetWriterFactory; +import org.apache.nifi.serialization.record.RecordSchema; +import org.apache.nifi.util.StopWatch; +import org.apache.plc4x.java.api.PlcConnection; +import org.apache.plc4x.java.api.exceptions.PlcConnectionException; +import org.apache.plc4x.java.api.messages.PlcReadRequest; +import org.apache.plc4x.java.api.messages.PlcReadResponse; +import org.apache.plc4x.java.api.model.PlcTag; +import org.apache.plc4x.nifi.record.Plc4xWriter; +import org.apache.plc4x.nifi.record.RecordPlc4xWriter; + +@Tags({"plc4x", "get", "input", "source", "record"}) +@SeeAlso({Plc4xSinkRecordProcessor.class, Plc4xListenRecordProcessor.class}) +@InputRequirement(InputRequirement.Requirement.INPUT_ALLOWED) +@CapabilityDescription("Processor able to read data from industrial PLCs using Apache PLC4X") +@WritesAttributes({ + @WritesAttribute(attribute = Plc4xSourceRecordProcessor.RESULT_ROW_COUNT, description = "Number of rows written into the output FlowFile"), + @WritesAttribute(attribute = Plc4xSourceRecordProcessor.RESULT_QUERY_EXECUTION_TIME, description = "Time between request and response from the PLC"), + @WritesAttribute(attribute = Plc4xSourceRecordProcessor.INPUT_FLOWFILE_UUID, description = "UUID of the input FlowFile") + }) +public class Plc4xSourceRecordProcessor extends BasePlc4xProcessor { + + public static final String RESULT_ROW_COUNT = "plc4x.read.row.count"; + public static final String RESULT_QUERY_EXECUTION_TIME = "plc4x.read.query.executiontime"; + public static final String INPUT_FLOWFILE_UUID = "input.flowfile.uuid"; + public static final String EXCEPTION = "plc4x.read.exception"; + + public static final PropertyDescriptor PLC_RECORD_WRITER_FACTORY = new PropertyDescriptor.Builder() + .name("plc4x-record-writer").displayName("Record Writer") + .description("Specifies the Controller Service to use for writing results to a FlowFile. The Record Writer may use Inherit Schema to emulate the inferred schema behavior, i.e. " + + "an explicit schema need not be defined in the writer, and will be supplied by the same logic used to infer the schema from the column types.") + .identifiesControllerService(RecordSetWriterFactory.class) + .required(true) + .build(); + + @Override + protected void init(final ProcessorInitializationContext context) { + super.init(context); + final Set r = new HashSet<>(super.getRelationships()); + this.relationships = Collections.unmodifiableSet(r); + + final List pds = new ArrayList<>(super.getSupportedPropertyDescriptors()); + pds.add(PLC_RECORD_WRITER_FACTORY); + this.properties = Collections.unmodifiableList(pds); + } + + + @Override + public void onTrigger(final ProcessContext context, final ProcessSession session) throws ProcessException { + + FlowFile fileToProcess = null; + if (context.hasIncomingConnection()) { + fileToProcess = session.get(); + + if (fileToProcess == null && context.hasNonLoopConnection()) { + return; + } + } + + final ComponentLog logger = getLogger(); + + + // Get an instance of a component able to read from a PLC. + final AtomicLong nrOfRows = new AtomicLong(0L); + final StopWatch executeTime = new StopWatch(true); + + final FlowFile resultSetFF; + if (fileToProcess == null) { + resultSetFF = session.create(); + } else { + resultSetFF = session.create(fileToProcess); + session.putAttribute(resultSetFF, INPUT_FLOWFILE_UUID, fileToProcess.getAttribute(CoreAttributes.UUID.key())); + } + + final FlowFile originalFlowFile = fileToProcess; + + Plc4xWriter plc4xWriter = new RecordPlc4xWriter(context.getProperty(PLC_RECORD_WRITER_FACTORY).asControllerService(RecordSetWriterFactory.class), + fileToProcess == null ? Collections.emptyMap() : fileToProcess.getAttributes()); + + + try { + session.write(resultSetFF, out -> { + final Map addressMap = getPlcAddressMap(context, originalFlowFile); + final RecordSchema recordSchema = getSchemaCache().retrieveSchema(addressMap); + final Map tags = getSchemaCache().retrieveTags(addressMap); + PlcReadRequest readRequest; + long nrOfRowsHere; + + final String connectionString = getConnectionString(context, originalFlowFile); + + if (debugEnabled) { + logger.debug("Get connection for plc: {}", connectionString); + } + + try (PlcConnection connection = getConnectionCache().getConnection(connectionString)) { + + readRequest = getReadRequest(logger, addressMap, tags, connection); + + PlcReadResponse readResponse = readRequest.execute().get(getTimeout(context, originalFlowFile), TimeUnit.MILLISECONDS); + + nrOfRowsHere = evaluateReadResponse(context, logger, originalFlowFile, plc4xWriter, out, recordSchema, readResponse); + + } catch (TimeoutException e) { + logger.error("Timeout reading the data from PLC", e); + getConnectionCache().removeCachedConnection(connectionString); + throw new ProcessException(e); + } catch (PlcConnectionException e) { + logger.error("Error getting the PLC connection", e); + throw new ProcessException("Got an a PlcConnectionException while trying to get a connection", e); + } catch (Exception e) { + logger.error("Exception reading the data from PLC", e); + throw (e instanceof ProcessException) ? (ProcessException) e : new ProcessException(e); + } + + if (recordSchema == null){ + if (debugEnabled) + logger.debug("Adding PlcTypes resolution into cache with key: " + addressMap); + getSchemaCache().addSchema( + addressMap, + readRequest.getTagNames(), + readRequest.getTags(), + plc4xWriter.getRecordSchema() + ); + } + nrOfRows.set(nrOfRowsHere); + + }); + + } catch (Exception e) { + logger.error("Exception reading the data from the PLC", e); + if (fileToProcess != null) { + session.putAttribute(fileToProcess, EXCEPTION, e.getLocalizedMessage()); + session.transfer(fileToProcess, REL_FAILURE); + } + session.remove(resultSetFF); + session.commitAsync(); + throw (e instanceof ProcessException) ? (ProcessException) e : new ProcessException(e); + } + + plc4xWriter.updateCounters(session); + long executionTimeElapsed = executeTime.getElapsed(TimeUnit.MILLISECONDS); + final Map attributesToAdd = new HashMap<>(); + attributesToAdd.put(RESULT_ROW_COUNT, String.valueOf(nrOfRows.get())); + attributesToAdd.put(RESULT_QUERY_EXECUTION_TIME, String.valueOf(executionTimeElapsed)); + attributesToAdd.putAll(plc4xWriter.getAttributesToAdd()); + + session.putAllAttributes(resultSetFF, attributesToAdd); + + logger.info("{} contains {} records; transferring to 'success'", resultSetFF, nrOfRows.get()); + + if (context.hasIncomingConnection()) { + session.getProvenanceReporter().fetch(resultSetFF, "Retrieved " + nrOfRows.get() + " rows", executionTimeElapsed); + } else { + session.getProvenanceReporter().receive(resultSetFF, "Retrieved " + nrOfRows.get() + " rows", executionTimeElapsed); + } + + if (fileToProcess != null) { + session.remove(fileToProcess); + } + session.transfer(resultSetFF, REL_SUCCESS); + } + +} diff --git a/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/AddressesAccessStrategy.java b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/AddressesAccessStrategy.java new file mode 100644 index 000000000..3794c0ba0 --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/AddressesAccessStrategy.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ +package org.apache.plc4x.nifi.address; + +import org.apache.nifi.components.AllowableValue; +import org.apache.nifi.components.PropertyDescriptor; +import org.apache.nifi.flowfile.FlowFile; +import org.apache.nifi.processor.ProcessContext; + +import java.util.List; +import java.util.Map; + +public interface AddressesAccessStrategy { + /** + * Returns the allowable value associated with the strategy. + * @return AllowableValue the allowable value associated + */ + AllowableValue getAllowableValue(); + + /** + * Returns a list of property descriptors needed in for the strategy. + * @return List of PropertyDescriptor needed for the strategy + */ + List getPropertyDescriptors(); + + /** + * Returns a map with the names and addresses of the tags. + * @param context the context of the processor + * @param flowFile the FlowFile being processed + * @return Map with the tag names and addresses + */ + Map extractAddresses(final ProcessContext context, final FlowFile flowFile); +} diff --git a/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/AddressesAccessUtils.java b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/AddressesAccessUtils.java new file mode 100644 index 000000000..d6e15046a --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/AddressesAccessUtils.java @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +package org.apache.plc4x.nifi.address; + +import org.apache.nifi.components.AllowableValue; +import org.apache.nifi.components.PropertyDescriptor; +import org.apache.nifi.expression.ExpressionLanguageScope; +import org.apache.nifi.processor.ProcessContext; +import org.apache.nifi.processor.util.JsonValidator; +import org.apache.nifi.processor.util.StandardValidators; +import org.apache.plc4x.java.DefaultPlcDriverManager; + +public class AddressesAccessUtils { + + private static final DefaultPlcDriverManager manager = new DefaultPlcDriverManager(); + + public static DefaultPlcDriverManager getManager() { + return manager; + } + + public static final AllowableValue ADDRESS_PROPERTY = new AllowableValue( + "property-address", + "Use Properties as Addresses", + "Each property will be treated as tag-address pairs after Expression Language is evaluated."); + + public static final AllowableValue ADDRESS_TEXT = new AllowableValue( + "text-address", + "Use 'Address Text' Property", + "Addresses will be obtained from 'Address Text' Property. It's content must be a valid JSON " + + "after Expression Language is evaluated. "); + + public static final AllowableValue ADDRESS_FILE = new AllowableValue( + "file-address", + "Use 'Address File' Property", + "Addresses will be obtained from the file in 'Address File' Property. It's content must be a valid JSON " + + "after Expression Language is evaluated. "); + + public static final PropertyDescriptor PLC_ADDRESS_ACCESS_STRATEGY = new PropertyDescriptor.Builder() + .name("plc4x-address-access-strategy") + .displayName("Address Access Strategy") + .description("Strategy used to obtain the PLC addresses") + .allowableValues(ADDRESS_PROPERTY, ADDRESS_TEXT, ADDRESS_FILE) + .defaultValue(ADDRESS_PROPERTY.getValue()) + .required(true) + .build(); + + public static final PropertyDescriptor ADDRESS_TEXT_PROPERTY = new PropertyDescriptor.Builder() + .name("text-address-property") + .displayName("Address Text") + .description("Must contain a valid JSON object after Expression Language is evaluated. " + + "Each field-value is treated as tag-address.") + .expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES) + .addValidator(new JsonValidator()) + .addValidator(new TextPropertyAccessStrategy.TagValidator(manager)) + .dependsOn(PLC_ADDRESS_ACCESS_STRATEGY, ADDRESS_TEXT) + .required(true) + .build(); + + public static final PropertyDescriptor ADDRESS_FILE_PROPERTY = new PropertyDescriptor.Builder() + .name("file-address-property") + .displayName("Address File") + .description("Must contain a valid path after Expression Language is evaluated. " + + "The file content must be a valid JSON and each field-value is treated as tag-address.") + .expressionLanguageSupported(ExpressionLanguageScope.FLOWFILE_ATTRIBUTES) + .addValidator(StandardValidators.FILE_EXISTS_VALIDATOR) + .addValidator(new FilePropertyAccessStrategy.TagValidator(manager)) + .dependsOn(PLC_ADDRESS_ACCESS_STRATEGY, ADDRESS_FILE) + .required(true) + .build(); + + public static AddressesAccessStrategy getAccessStrategy(final ProcessContext context) { + String value = context.getProperty(PLC_ADDRESS_ACCESS_STRATEGY).getValue(); + if (ADDRESS_PROPERTY.getValue().equalsIgnoreCase(value)) + return new DynamicPropertyAccessStrategy(); + else if (ADDRESS_TEXT.getValue().equalsIgnoreCase(value)) + return new TextPropertyAccessStrategy(); + else if (ADDRESS_FILE.getValue().equalsIgnoreCase(value)) + return new FilePropertyAccessStrategy(); + return null; + } +} diff --git a/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/BaseAccessStrategy.java b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/BaseAccessStrategy.java new file mode 100644 index 000000000..5f3ca7f4d --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/BaseAccessStrategy.java @@ -0,0 +1,124 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +package org.apache.plc4x.nifi.address; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import org.apache.nifi.components.AllowableValue; +import org.apache.nifi.components.PropertyDescriptor; +import org.apache.nifi.components.ValidationContext; +import org.apache.nifi.components.ValidationResult; +import org.apache.nifi.components.Validator; +import org.apache.nifi.flowfile.FlowFile; +import org.apache.nifi.processor.ProcessContext; +import org.apache.plc4x.java.DefaultPlcDriverManager; +import org.apache.plc4x.java.api.PlcDriver; +import org.apache.plc4x.nifi.BasePlc4xProcessor; + + +public abstract class BaseAccessStrategy implements AddressesAccessStrategy{ + private boolean isInitializated = false; + private boolean isDynamic; + protected Map cachedAddresses = null; + + protected AllowableValue allowableValue; + protected List propertyDescriptors = new ArrayList<>(); + + protected Map getCachedAddresses() { + return cachedAddresses; + } + + public Map extractAddressesFromResources(final ProcessContext context, final FlowFile flowFile) { + throw new UnsupportedOperationException("Method 'extractAddressesFromResources' not implemented"); + } + + + @Override + public Map extractAddresses(final ProcessContext context, final FlowFile flowFile) { + if (!isInitializated) { + getPropertyDescriptors().forEach(prop -> { + if (context.getProperty(prop).isExpressionLanguagePresent()){ + isDynamic = true; + } + }); + isInitializated = true; + } + + Map result = getCachedAddresses(); + if (result == null) { + result = extractAddressesFromResources(context, flowFile); + if (!isDynamic) { + cachedAddresses = result; + } + } + return result; + } + + public static class TagValidator implements Validator { + + private final DefaultPlcDriverManager manager; + + public TagValidator(DefaultPlcDriverManager manager) { + this.manager = manager; + } + + protected void checkTags(PlcDriver driver, Collection tags) { + for (String tag : tags) { + driver.prepareTag(tag); + } + } + + protected Collection getTags(String input) throws Exception { + throw new UnsupportedOperationException("Method 'getTags' not implemented"); + } + + @Override + public ValidationResult validate(String subject, String input, ValidationContext context) { + String connectionString = context.getProperty(BasePlc4xProcessor.PLC_CONNECTION_STRING).getValue(); + + if (context.isExpressionLanguageSupported(subject) && context.isExpressionLanguagePresent(input) || + context.isExpressionLanguagePresent(connectionString)) { + return new ValidationResult.Builder().subject(subject).input(input) + .explanation("Expression Language Present").valid(true).build(); + } + + try { + PlcDriver driver = manager.getDriverForUrl(connectionString); + + if (!context.isExpressionLanguagePresent(input)) { + checkTags(driver, getTags(input)); + } + + }catch (Exception e) { + return new ValidationResult.Builder().subject(subject) + .explanation(e.getLocalizedMessage()) + .valid(false) + .build(); + } + + return new ValidationResult.Builder().subject(subject) + .explanation("") + .valid(true) + .build(); + } + } + +} diff --git a/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/DynamicPropertyAccessStrategy.java b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/DynamicPropertyAccessStrategy.java new file mode 100644 index 000000000..879766f6d --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/DynamicPropertyAccessStrategy.java @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +package org.apache.plc4x.nifi.address; + +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.nifi.components.AllowableValue; +import org.apache.nifi.components.PropertyDescriptor; +import org.apache.nifi.flowfile.FlowFile; +import org.apache.nifi.processor.ProcessContext; +import org.apache.plc4x.java.DefaultPlcDriverManager; + + +public class DynamicPropertyAccessStrategy extends BaseAccessStrategy{ + + @Override + public AllowableValue getAllowableValue() { + return AddressesAccessUtils.ADDRESS_PROPERTY; + } + + @Override + public List getPropertyDescriptors() { + return List.of(); + } + + @Override + public Map extractAddressesFromResources(final ProcessContext context, final FlowFile flowFile) { + return extractAddressesFromAttributes(context, flowFile); + } + + private Map extractAddressesFromAttributes(final ProcessContext context, final FlowFile flowFile) { + Map addressMap = new HashMap<>(); + + context.getProperties().keySet().stream().filter(PropertyDescriptor::isDynamic).forEach( + t -> addressMap.put(t.getName(), context.getProperty(t.getName()).evaluateAttributeExpressions(flowFile).getValue())); + + return addressMap; + } + + + public static class TagValidator extends BaseAccessStrategy.TagValidator { + public TagValidator(DefaultPlcDriverManager manager) { + super(manager); + } + + @Override + protected Collection getTags(String input) throws Exception { + return List.of(input); + } + } + +} diff --git a/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/FilePropertyAccessStrategy.java b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/FilePropertyAccessStrategy.java new file mode 100644 index 000000000..995ae0be5 --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/FilePropertyAccessStrategy.java @@ -0,0 +1,78 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +package org.apache.plc4x.nifi.address; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import org.apache.nifi.components.AllowableValue; +import org.apache.nifi.components.PropertyDescriptor; +import org.apache.nifi.flowfile.FlowFile; +import org.apache.nifi.processor.ProcessContext; +import org.apache.nifi.processor.exception.ProcessException; +import org.apache.plc4x.java.DefaultPlcDriverManager; + +import com.fasterxml.jackson.databind.ObjectMapper; + +public class FilePropertyAccessStrategy extends BaseAccessStrategy { + + @Override + public AllowableValue getAllowableValue() { + return AddressesAccessUtils.ADDRESS_FILE; + } + + @Override + public List getPropertyDescriptors() { + return List.of(AddressesAccessUtils.ADDRESS_FILE_PROPERTY); + } + + @Override + public Map extractAddressesFromResources(final ProcessContext context, final FlowFile flowFile) throws ProcessException{ + try { + return extractAddressesFromFile(context.getProperty(AddressesAccessUtils.ADDRESS_FILE_PROPERTY).evaluateAttributeExpressions(flowFile).getValue()); + } catch (Exception e) { + throw new ProcessException(e.toString()); + } + } + + public static Map extractAddressesFromFile(String fileName) throws IOException { + ObjectMapper mapper = new ObjectMapper(); + + Path filePath = Path.of(fileName); + InputStream input = Files.newInputStream(filePath, StandardOpenOption.READ); + + return mapper.readerForMapOf(String.class).readValue(input); + } + + public static class TagValidator extends BaseAccessStrategy.TagValidator { + public TagValidator(DefaultPlcDriverManager manager) { + super(manager); + } + + @Override + protected Collection getTags(String input) throws Exception { + return FilePropertyAccessStrategy.extractAddressesFromFile(input).values(); + } + } +} diff --git a/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/TextPropertyAccessStrategy.java b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/TextPropertyAccessStrategy.java new file mode 100644 index 000000000..78149f361 --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/address/TextPropertyAccessStrategy.java @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +package org.apache.plc4x.nifi.address; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import org.apache.nifi.components.AllowableValue; +import org.apache.nifi.components.PropertyDescriptor; +import org.apache.nifi.flowfile.FlowFile; +import org.apache.nifi.processor.ProcessContext; +import org.apache.nifi.processor.exception.ProcessException; +import org.apache.plc4x.java.DefaultPlcDriverManager; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +public class TextPropertyAccessStrategy extends BaseAccessStrategy { + + @Override + public AllowableValue getAllowableValue() { + return AddressesAccessUtils.ADDRESS_TEXT; + } + + @Override + public List getPropertyDescriptors() { + return List.of(AddressesAccessUtils.ADDRESS_TEXT_PROPERTY); + } + + @Override + public Map extractAddressesFromResources(final ProcessContext context, final FlowFile flowFile) throws ProcessException{ + try { + return extractAddressesFromText(context.getProperty(AddressesAccessUtils.ADDRESS_TEXT_PROPERTY).evaluateAttributeExpressions(flowFile).getValue()); + } catch (Exception e) { + throw new ProcessException(e.toString()); + } + } + + private static Map extractAddressesFromText(String input) throws JsonProcessingException { + ObjectMapper mapper = new ObjectMapper(); + + return mapper.readerForMapOf(String.class).readValue(input); + } + + public static class TagValidator extends BaseAccessStrategy.TagValidator { + public TagValidator(DefaultPlcDriverManager manager) { + super(manager); + } + + @Override + protected Collection getTags(String input) throws Exception { + return TextPropertyAccessStrategy.extractAddressesFromText(input).values(); + } + } +} diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/Plc4xReadResponseRecordSet.java b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/Plc4xReadResponseRecordSet.java similarity index 81% rename from plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/Plc4xReadResponseRecordSet.java rename to plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/Plc4xReadResponseRecordSet.java index f546159e4..1f3c50a20 100644 --- a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/Plc4xReadResponseRecordSet.java +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/Plc4xReadResponseRecordSet.java @@ -26,8 +26,7 @@ import java.util.Set; import java.util.concurrent.atomic.AtomicReference; -import org.apache.avro.Schema; -import org.apache.nifi.avro.AvroTypeUtil; +import org.apache.nifi.logging.ComponentLog; import org.apache.nifi.serialization.record.MapRecord; import org.apache.nifi.serialization.record.Record; import org.apache.nifi.serialization.record.RecordField; @@ -35,27 +34,25 @@ import org.apache.nifi.serialization.record.RecordSet; import org.apache.plc4x.java.api.messages.PlcReadResponse; import org.apache.plc4x.java.api.value.PlcValue; -import org.apache.plc4x.java.spi.messages.DefaultPlcSubscriptionEvent; -import org.apache.plc4x.java.spi.messages.utils.ResponseItem; +import org.apache.plc4x.java.spi.drivers.messages.DefaultPlcSubscriptionEvent; import org.apache.plc4x.nifi.util.Plc4xCommon; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; public class Plc4xReadResponseRecordSet implements RecordSet, Closeable { - private static final Logger logger = LoggerFactory.getLogger(Plc4xReadResponseRecordSet.class); + private final ComponentLog logger; private final PlcReadResponse readResponse; - private Set rsColumnNames; + private final Set rsColumnNames; private boolean moreRows; - private final boolean debugEnabled = logger.isDebugEnabled(); private final String timestampFieldName; private boolean isSubscription = false; private Instant timestamp; private final AtomicReference recordSchema = new AtomicReference<>(null); - public Plc4xReadResponseRecordSet(final PlcReadResponse readResponse, RecordSchema recordSchema, String timestampFieldName) { + public Plc4xReadResponseRecordSet(final PlcReadResponse readResponse, RecordSchema recordSchema, String timestampFieldName, ComponentLog logger) { this.timestampFieldName = timestampFieldName; this.readResponse = readResponse; + this.logger = logger; + if (!isSubscription) { timestamp = Instant.now(); } @@ -63,7 +60,7 @@ public Plc4xReadResponseRecordSet(final PlcReadResponse readResponse, RecordSche isSubscription = readResponse.getRequest() == null; - if (debugEnabled) + if (this.logger.isDebugEnabled()) logger.debug("Creating record schema from PlcReadResponse"); Map responseDataStructure; @@ -75,12 +72,12 @@ public Plc4xReadResponseRecordSet(final PlcReadResponse readResponse, RecordSche rsColumnNames = responseDataStructure.keySet(); if (recordSchema == null) { - Schema avroSchema = Plc4xCommon.createSchema(responseDataStructure, this.timestampFieldName); - this.recordSchema.set(AvroTypeUtil.createSchema(avroSchema)); + RecordSchema schema = Plc4xCommon.createSchema(responseDataStructure, this.timestampFieldName); + this.recordSchema.set(schema); } else { this.recordSchema.set(recordSchema); } - if (debugEnabled) + if (this.logger.isDebugEnabled()) logger.debug("Record schema from PlcReadResponse successfuly created."); } @@ -88,13 +85,14 @@ public Plc4xReadResponseRecordSet(final PlcReadResponse readResponse, RecordSche public Map plc4xSubscriptionResponseRecordSet(final DefaultPlcSubscriptionEvent subscriptionEvent) { moreRows = true; - if (debugEnabled) + if (logger.isDebugEnabled()) logger.debug("Creating record schema from DefaultPlcSubscriptionEvent"); Map responseDataStructure = new HashMap<>(); - for (Map.Entry> entry : subscriptionEvent.getValues().entrySet()) { - responseDataStructure.put(entry.getKey(), entry.getValue().getValue()); + for (String tagName : subscriptionEvent.getTagNames()) { + PlcValue plcValue = subscriptionEvent.getPlcValue(tagName); + responseDataStructure.put(tagName, plcValue); } return responseDataStructure; @@ -141,8 +139,8 @@ public void close() { protected Record createRecord(final PlcReadResponse readResponse) { final Map values = new HashMap<>(getSchema().getFieldCount()); - if (debugEnabled) - logger.debug("creating record."); + if (logger.isDebugEnabled()) + logger.debug("Creating record from plc response"); for (final RecordField tag : getSchema().getFields()) { final String tagName = tag.getFieldName(); @@ -160,7 +158,8 @@ protected Record createRecord(final PlcReadResponse readResponse) { value = null; } - logger.trace("Adding {} tag value to record.", tagName); + if (logger.isDebugEnabled()) + logger.debug("Adding {} tag value to record.", tagName); values.put(tagName, value); } @@ -171,8 +170,8 @@ protected Record createRecord(final PlcReadResponse readResponse) { values.put(timestampFieldName, timestamp.toEpochMilli()); } - if (debugEnabled) - logger.debug("added timestamp tag to record."); + if (logger.isDebugEnabled()) + logger.debug("Adding timestamp tag {} to record.", timestampFieldName); return new MapRecord(getSchema(), values); diff --git a/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/Plc4xWriter.java b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/Plc4xWriter.java new file mode 100644 index 000000000..534add9da --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/Plc4xWriter.java @@ -0,0 +1,82 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.nifi.record; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.Collections; +import java.util.Map; + +import org.apache.nifi.flowfile.FlowFile; +import org.apache.nifi.logging.ComponentLog; +import org.apache.nifi.processor.ProcessSession; +import org.apache.nifi.serialization.record.RecordSchema; +import org.apache.plc4x.java.api.messages.PlcReadResponse; + +public interface Plc4xWriter { + /** + * Writes the given result set out to the given output stream, possibly applying a callback as each row is processed. + * @param response the PlcReadResponse to be written + * @param outputStream the OutputStream to write the result set to + * @param logger a common logger that can be used to log messages during write + * @return the number of rows written to the output stream + * @throws Exception if any errors occur during the writing of the result set to the output stream + */ + long writePlcReadResponse(PlcReadResponse response, OutputStream outputStream, ComponentLog logger, RecordSchema recordSchema, String timestampFieldName) throws Exception; + long writePlcReadResponse(PlcReadResponse response, OutputStream outputStream, ComponentLog logger, RecordSchema recordSchema, FlowFile originalFlowFile, String timestampFieldName) throws Exception; + + /** + * Returns a map of attribute key/value pairs to be added to any outgoing flow file(s). The default implementation is to return an empty map. + * @return a map of attribute key/value pairs + */ + default Map getAttributesToAdd() { + return Collections.emptyMap(); + } + + /** + * Updates any session counters as a result of processing result sets. The default implementation is empty, no counters will be updated. + * @param session the session upon which to update counters + */ + default void updateCounters(ProcessSession session) { + } + + /** + * Writes an empty result set to the output stream. In some cases a ResultSet might not have any viable rows, but will throw an error or + * behave unexpectedly if rows are attempted to be retrieved. This method indicates the implementation should write whatever output is + * appropriate for a result set with no rows. + * @param outputStream the OutputStream to write the empty result set to + * @param logger a common logger that can be used to log messages during write + * @throws IOException if any errors occur during the writing of an empty result set to the output stream + */ + void writeEmptyPlcReadResponse(OutputStream outputStream, ComponentLog logger) throws IOException; + void writeEmptyPlcReadResponse(OutputStream outputStream, ComponentLog logger, FlowFile originalFlowFile) throws IOException; + + /** + * Returns the MIME type of the output format. This can be used in FlowFile attributes or to perform format-specific processing as necessary. + * @return the MIME type string of the output format. + */ + String getMimeType(); + + + /** + * Returns the Record Schema used to create the output flowfiles. Used to store in schema cache. + * @return the Record Schema of the output. + */ + RecordSchema getRecordSchema(); +} diff --git a/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/RecordPlc4xWriter.java b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/RecordPlc4xWriter.java new file mode 100644 index 000000000..ee22588a9 --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/RecordPlc4xWriter.java @@ -0,0 +1,191 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.nifi.record; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.nifi.flowfile.FlowFile; +import org.apache.nifi.flowfile.attributes.CoreAttributes; +import org.apache.nifi.logging.ComponentLog; +import org.apache.nifi.processor.ProcessSession; +import org.apache.nifi.serialization.RecordSetWriter; +import org.apache.nifi.serialization.RecordSetWriterFactory; +import org.apache.nifi.serialization.WriteResult; +import org.apache.nifi.serialization.record.Record; +import org.apache.nifi.serialization.record.RecordSchema; +import org.apache.nifi.serialization.record.RecordSet; +import org.apache.plc4x.java.api.messages.PlcReadResponse; + +public class RecordPlc4xWriter implements Plc4xWriter { + + private final RecordSetWriterFactory recordSetWriterFactory; + private final AtomicReference writeResultRef; + private final Map originalAttributes; + private String mimeType; + + private RecordSet fullRecordSet; + private RecordSchema writeSchema; + + + public RecordPlc4xWriter(RecordSetWriterFactory recordSetWriterFactory, Map originalAttributes) { + this.recordSetWriterFactory = recordSetWriterFactory; + this.writeResultRef = new AtomicReference<>(); + this.originalAttributes = originalAttributes; + } + + @Override + public long writePlcReadResponse(PlcReadResponse response, OutputStream outputStream, ComponentLog logger, + RecordSchema recordSchema, String timestampFieldName) throws Exception { + + if (fullRecordSet == null) { + fullRecordSet = new Plc4xReadResponseRecordSetWithCallback(response, recordSchema, timestampFieldName, logger); + writeSchema = recordSetWriterFactory.getSchema(originalAttributes, fullRecordSet.getSchema()); + } + Map empty = new HashMap<>(); + try (final RecordSetWriter resultSetWriter = recordSetWriterFactory.createWriter(logger, writeSchema, outputStream, empty)) { + writeResultRef.set(resultSetWriter.write(fullRecordSet)); + if (mimeType == null) { + mimeType = resultSetWriter.getMimeType(); + } + return writeResultRef.get().getRecordCount(); + } catch (final Exception e) { + throw new IOException(e); + } + } + + @Override + public long writePlcReadResponse(PlcReadResponse response, OutputStream outputStream, ComponentLog logger, + RecordSchema recordSchema, FlowFile originalFlowFile, String timestampFieldName) throws Exception { + + if (fullRecordSet == null) { + fullRecordSet = new Plc4xReadResponseRecordSetWithCallback(response, recordSchema, timestampFieldName, logger); + writeSchema = recordSetWriterFactory.getSchema(originalAttributes, fullRecordSet.getSchema()); + } + + RecordSetWriter resultSetWriter = null; + try { + if (originalFlowFile != null) { + try { + resultSetWriter = recordSetWriterFactory.createWriter(logger, writeSchema, outputStream, originalFlowFile); + } catch (final Exception e) { + throw new IOException(e); + } + } else { + resultSetWriter = recordSetWriterFactory.createWriter(logger, writeSchema, outputStream, Collections.emptyMap()); + } + + writeResultRef.set(resultSetWriter.write(fullRecordSet)); + if (mimeType == null) { + mimeType = resultSetWriter.getMimeType(); + } + return writeResultRef.get().getRecordCount(); + } finally { + if (resultSetWriter != null) { + resultSetWriter.close(); + } + } + } + + + @Override + public void writeEmptyPlcReadResponse(OutputStream outputStream, ComponentLog logger) throws IOException { + Map empty = new HashMap<>(); + try (final RecordSetWriter resultSetWriter = recordSetWriterFactory.createWriter(logger, writeSchema, outputStream, empty)) { + mimeType = resultSetWriter.getMimeType(); + resultSetWriter.beginRecordSet(); + resultSetWriter.finishRecordSet(); + } catch (final Exception e) { + throw new IOException(e); + } + } + + @Override + public void writeEmptyPlcReadResponse(OutputStream outputStream, ComponentLog logger, FlowFile originalFlowFile) throws IOException { + try (final RecordSetWriter resultSetWriter = recordSetWriterFactory.createWriter(logger, writeSchema, outputStream, originalFlowFile)) { + mimeType = resultSetWriter.getMimeType(); + resultSetWriter.beginRecordSet(); + resultSetWriter.finishRecordSet(); + } catch (final Exception e) { + throw new IOException(e); + } + } + + @Override + public String getMimeType() { + return mimeType; + } + + @Override + public Map getAttributesToAdd() { + Map attributesToAdd = new HashMap<>(); + attributesToAdd.put(CoreAttributes.MIME_TYPE.key(), mimeType); + // Add any attributes from the record writer (if present) + final WriteResult result = writeResultRef.get(); + if (result != null) { + if (result.getAttributes() != null) { + attributesToAdd.putAll(result.getAttributes()); + } + attributesToAdd.put("record.count", String.valueOf(result.getRecordCount())); + } + return attributesToAdd; + } + + @Override + public void updateCounters(ProcessSession session) { + final WriteResult result = writeResultRef.get(); + if (result != null) { + session.adjustCounter("Records Written", result.getRecordCount(), false); + } + } + + private static class Plc4xReadResponseRecordSetWithCallback extends Plc4xReadResponseRecordSet { + + public Plc4xReadResponseRecordSetWithCallback(final PlcReadResponse readResponse, + RecordSchema recordSchema, String timestampFieldName, ComponentLog logger) { + + super(readResponse, recordSchema, timestampFieldName, logger); + } + + @Override + public Record next() throws IOException { + if (hasMoreRows()) { + PlcReadResponse response = getReadResponse(); + final Record record = createRecord(response); + setMoreRows(false); + return record; + } else { + return null; + } + } + } + + public RecordSchema getRecordSchema() { + try { + return this.fullRecordSet.getSchema(); + } catch (IOException e) { + return null; + } + } + +} diff --git a/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/SchemaCache.java b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/SchemaCache.java new file mode 100644 index 000000000..b0078922b --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/SchemaCache.java @@ -0,0 +1,138 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.nifi.record; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReferenceArray; + +import org.apache.nifi.serialization.record.RecordSchema; +import org.apache.plc4x.java.api.model.PlcTag; + +public class SchemaCache { + private ConcurrentMap schemaMap = new ConcurrentHashMap<>(); + private AtomicReferenceArray schemaAppendOrder = new AtomicReferenceArray<>(0); + private final AtomicInteger nextSchemaPosition = new AtomicInteger(0); + private final AtomicInteger cacheSize = new AtomicInteger(0); + + /** Creates a schema cache with first-in-first-out replacement policy. Stores PlcTags and RecordSchema used for PlcResponse serialization + * @param cacheSize initial cache size + */ + public SchemaCache(int cacheSize) { + this.cacheSize.set(cacheSize); + } + + /** Empties and restart the cache with the given size + * @param cacheSize size of schema cache + */ + public void restartCache(int cacheSize) { + this.cacheSize.set(cacheSize); + this.schemaAppendOrder = new AtomicReferenceArray<>(cacheSize); + this.schemaMap = new ConcurrentHashMap<>(); + this.nextSchemaPosition.set(0); + } + + + /** Adds the schema to the cache if not present. When the cache is full first-in-first-out replacement policy applies + * @param schemaIdentifier tagName-address map used to store the schema + * @param tagsNames list of tag names + * @param tagsList list of PlcTag's + * @param schema record schema used for PlcResponse serialization. Can be null + */ + public void addSchema(final Map schemaIdentifier, final Set tagsNames, final List tagsList, final RecordSchema schema) { + final String identifier = schemaIdentifier.toString(); + if (!schemaMap.containsKey(identifier)){ + if (nextSchemaPosition.get() == cacheSize.get()){ + nextSchemaPosition.set(0); + } + removeSchema(schemaAppendOrder.get(nextSchemaPosition.get())); + + Map tags = new HashMap<>(); + for (int i=0; i schemaIdentifier) { + if (schemaMap.containsKey(schemaIdentifier.toString())){ + return schemaMap.get(schemaIdentifier.toString()).getSchema(); + } + return null; + } + + /** Retrieves tags from the cache if found + * @param schemaIdentifier tagName-address map used to store the schema + * @return Map between tag names and the corresponding PlcTag. Null if not found + */ + public Map retrieveTags(final Map schemaIdentifier) { + if (schemaMap.containsKey(schemaIdentifier.toString())){ + return schemaMap.get(schemaIdentifier.toString()).getTags(); + } + return null; + } + + protected int getNextSchemaPosition() { + return nextSchemaPosition.get(); + } + + protected int getCacheSize() { + return cacheSize.get(); + } + + public static class SchemaContainer { + private final RecordSchema schema; + private final Map tags; + + public Map getTags() { + return tags; + } + + public RecordSchema getSchema() { + return schema; + } + + SchemaContainer(Map tags, RecordSchema schema){ + this.tags = tags; + this.schema = schema; + } + } +} diff --git a/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/subscription/Plc4xListenerDispatcher.java b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/subscription/Plc4xListenerDispatcher.java new file mode 100644 index 000000000..80a7833aa --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/subscription/Plc4xListenerDispatcher.java @@ -0,0 +1,164 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.nifi.subscription; + +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.apache.nifi.logging.ComponentLog; +import org.apache.nifi.processor.exception.ProcessException; +import org.apache.plc4x.java.DefaultPlcDriverManager; +import org.apache.plc4x.java.api.PlcConnection; +import org.apache.plc4x.java.api.PlcConnectionFactory; +import org.apache.plc4x.java.api.exceptions.PlcConnectionException; +import org.apache.plc4x.java.api.exceptions.PlcProtocolException; +import org.apache.plc4x.java.api.messages.PlcSubscriptionEvent; +import org.apache.plc4x.java.api.messages.PlcSubscriptionRequest; +import org.apache.plc4x.java.api.messages.PlcSubscriptionResponse; +import org.apache.plc4x.java.api.model.PlcSubscriptionHandle; + +public class Plc4xListenerDispatcher implements Runnable { + + private final PlcConnectionFactory connectionFactory; + private final Plc4xSubscriptionType subscriptionType; + private final Long cyclingPollingInterval; + private final ComponentLog logger; + private boolean running = false; + private final BlockingQueue events; + private PlcConnection connection; + private final Long timeout; + private final BlockingQueue queuedEvents; + + public boolean isRunning() { + return running; + } + + public Plc4xListenerDispatcher(Long timeout, Plc4xSubscriptionType subscriptionType, Long cyclingPollingInterval, ComponentLog logger, final BlockingQueue events) { + this.timeout = timeout; + this.subscriptionType = subscriptionType; + this.cyclingPollingInterval = cyclingPollingInterval; + this.logger = logger; + this.events = events; + this.queuedEvents = new LinkedBlockingQueue<>(); + this.connectionFactory = new DefaultPlcDriverManager(); + } + + /** + * Opens the dispatcher + * + * @param plcConnectionString the connection string for the to the plc + * @param tags a map of tag identifier and tag address the + * dispatcher will try subscribing + * @throws Exception exception + * @throws PlcConnectionException connection exception + */ + public void open(String plcConnectionString, Map tags) throws PlcConnectionException, Exception { + connection = connectionFactory.getConnection(plcConnectionString); + + if (!connection.getMetadata().isSubscribeSupported()) { + throw new PlcProtocolException("This connection does not support subscription"); + } + + if (logger.isDebugEnabled()){ + logger.debug("Creating PLC {} subscription for connection {} with tags {}", subscriptionType, plcConnectionString, tags); + } + + PlcSubscriptionRequest.Builder builder = connection.subscriptionRequestBuilder(); + + for (Map.Entry entry : tags.entrySet()) { + switch (subscriptionType) { + case CHANGE: + builder.addChangeOfStateTagAddress(entry.getKey(), entry.getValue()); + break; + case CYCLIC: + builder.addCyclicTagAddress(entry.getKey(), entry.getValue(), Duration.ofMillis(cyclingPollingInterval)); + break; + case EVENT: + builder.addEventTagAddress(entry.getKey(), entry.getValue()); + } + } + PlcSubscriptionRequest subscriptionRequest = builder.build(); + PlcSubscriptionResponse subscriptionResponse; + try { + if (logger.isDebugEnabled()){ + logger.debug("Submitting PLC {} subscription for connection {} with tags {}", subscriptionType, plcConnectionString, tags); + } + subscriptionResponse = subscriptionRequest.execute().get(timeout, TimeUnit.MILLISECONDS); + + } catch (InterruptedException e) { + logger.error("InterruptedException reading the data from PLC", e); + throw e; + } catch (TimeoutException e) { + logger.error("Timeout connection to PLC", e); + throw e; + } catch (Exception e) { + logger.error("Exception reading the data from PLC", e); + throw (e instanceof ProcessException) ? (ProcessException) e : new ProcessException(e); + } + + + if (logger.isDebugEnabled()){ + logger.debug("Registering handlers for PLC {} subscription for connection {} with tags {}", subscriptionType, plcConnectionString, tags); + } + for (PlcSubscriptionHandle handle : subscriptionResponse.getSubscriptionHandles()) { + handle.register(queuedEvents::offer); + } + + running = true; + } + + /** + * Closes all listeners and stops all handler threads. + */ + public void close() { + if (logger.isDebugEnabled()) { + logger.debug("Closing listener for "); + } + running = false; + try { + connection.close(); + } catch (Exception e) { + logger.debug(e.getMessage()); + } + } + + /** + * Runs the thread. If no subscription events are received in less than timeout milliseconds the dispatcher is closed. + */ + @Override + public void run() { + while (running) { + try { + // If there is a new event before timeout save it, else reopen the connection + PlcSubscriptionEvent event = queuedEvents.poll(timeout, TimeUnit.MILLISECONDS); + if (event != null){ + events.put(event); + } else { + close(); + } + } catch (InterruptedException e){ + close(); + } + } + } +} diff --git a/plc4j/tools/ui/frontend/project/src/App.css b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/subscription/Plc4xSubscriptionType.java similarity index 73% rename from plc4j/tools/ui/frontend/project/src/App.css rename to plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/subscription/Plc4xSubscriptionType.java index 620845539..bdc238768 100644 --- a/plc4j/tools/ui/frontend/project/src/App.css +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/subscription/Plc4xSubscriptionType.java @@ -16,4 +16,10 @@ * specific language governing permissions and limitations * under the License. */ +package org.apache.plc4x.nifi.subscription; +public enum Plc4xSubscriptionType { + CHANGE, // of state (Event is sent as soon as a value changes) + CYCLIC, //(The Event is sent in regular cyclic intervals) + EVENT //(The Event is usually explicitly sent form the PLC as a signal) +} \ No newline at end of file diff --git a/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/util/Plc4xCommon.java b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/util/Plc4xCommon.java new file mode 100644 index 000000000..f936b1b59 --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/java/org/apache/plc4x/nifi/util/Plc4xCommon.java @@ -0,0 +1,229 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.nifi.util; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.apache.nifi.serialization.SimpleRecordSchema; +import org.apache.nifi.serialization.record.DataType; +import org.apache.nifi.serialization.record.RecordField; +import org.apache.nifi.serialization.record.RecordFieldType; +import org.apache.nifi.serialization.record.RecordSchema; +import org.apache.plc4x.java.api.value.PlcValue; +import org.apache.plc4x.java.spi.values.PlcBOOL; +import org.apache.plc4x.java.spi.values.PlcBYTE; +import org.apache.plc4x.java.spi.values.PlcCHAR; +import org.apache.plc4x.java.spi.values.PlcDATE; +import org.apache.plc4x.java.spi.values.PlcDATE_AND_TIME; +import org.apache.plc4x.java.spi.values.PlcDINT; +import org.apache.plc4x.java.spi.values.PlcDWORD; +import org.apache.plc4x.java.spi.values.PlcINT; +import org.apache.plc4x.java.spi.values.PlcLINT; +import org.apache.plc4x.java.spi.values.PlcLREAL; +import org.apache.plc4x.java.spi.values.PlcLWORD; +import org.apache.plc4x.java.spi.values.PlcList; +import org.apache.plc4x.java.spi.values.PlcREAL; +import org.apache.plc4x.java.spi.values.PlcSINT; +import org.apache.plc4x.java.spi.values.PlcTIME; +import org.apache.plc4x.java.spi.values.PlcTIME_OF_DAY; +import org.apache.plc4x.java.spi.values.PlcUDINT; +import org.apache.plc4x.java.spi.values.PlcUINT; +import org.apache.plc4x.java.spi.values.PlcULINT; +import org.apache.plc4x.java.spi.values.PlcUSINT; +import org.apache.plc4x.java.spi.values.PlcWCHAR; +import org.apache.plc4x.java.spi.values.PlcWORD; + +public class Plc4xCommon { + + private Plc4xCommon (){} + + /** + * This method is used to create a NiFi record schema from the PlcReadResponse object. + * It is directly used from the RecordPlc4xWriter.writePlcReadResponse() method. + * However, to make sure output schema does not change, it is built from the processor configuration (variable memory addresses). + * At the moment this method does not handle the following Object Types: PlcValueAdapter, PlcIECValue, PlcSimpleValue + * + * @param responseDataStructure: a map that reflects the structure of the answer given by the PLC when making a Read Request. + * @return RecordSchema built from responseDataStructure. + */ + public static RecordSchema createSchema(Map responseDataStructure, String timestampFieldName){ + List recordFields = new ArrayList<>(); + + for (Map.Entry entry : responseDataStructure.entrySet()) { + RecordField f = new RecordField(entry.getKey(), getDataType(entry.getValue())); + recordFields.add(f); + } + + recordFields.add(new RecordField(timestampFieldName, RecordFieldType.BIGINT.getDataType())); + + return new SimpleRecordSchema(recordFields); + } + + private static DataType getDataType(final Object valueOriginal) { + + PlcValue value = (PlcValue) valueOriginal; + + // Lists. Inner data type default to STRING if empty list + if (value instanceof PlcList && value.isList()){ + if (!value.getList().isEmpty()) { + return RecordFieldType.ARRAY.getArrayDataType(getDataType(value.getList().get(0))); + } + return RecordFieldType.ARRAY.getArrayDataType(RecordFieldType.STRING.getDataType()); + } + + // 8 bits + if (value instanceof PlcBOOL && value.isBoolean()) + return RecordFieldType.BOOLEAN.getDataType(); + if (value instanceof PlcBYTE && (value.isByte() || value.isShort())) + return RecordFieldType.SHORT.getDataType(); + if (value instanceof PlcCHAR && value.isShort()) + return RecordFieldType.STRING.getDataType(); + if ((value instanceof PlcSINT || value instanceof PlcUSINT) && (value.isShort() || value.isInteger())) + return RecordFieldType.SHORT.getDataType(); + + + // 16 bits + if (value instanceof PlcWORD && (value.isInteger() || value.isShort())) + return RecordFieldType.STRING.getDataType(); + if (value instanceof PlcINT && value.isInteger()) + return RecordFieldType.INT.getDataType(); + if (value instanceof PlcUINT && value.isInteger()) + return RecordFieldType.INT.getDataType(); + if (value instanceof PlcWCHAR || value instanceof PlcDWORD) + return RecordFieldType.STRING.getDataType(); + + // 32 bits + if (value instanceof PlcREAL && value.isFloat()) + return RecordFieldType.FLOAT.getDataType(); + if ((value instanceof PlcDINT) && value.isInteger()) + return RecordFieldType.INT.getDataType(); + if (value instanceof PlcDWORD && value.isInteger()) + return RecordFieldType.STRING.getDataType(); + + // 64 bits + if ((value instanceof PlcLINT || value instanceof PlcUDINT) && value.isLong()) + return RecordFieldType.LONG.getDataType(); + if (value instanceof PlcULINT) + return RecordFieldType.BIGINT.getDataType(); + if (value instanceof PlcLREAL && value.isDouble()) + return RecordFieldType.DOUBLE.getDataType(); + if (value instanceof PlcLWORD && (value.isLong() || value.isBigInteger())) + return RecordFieldType.STRING.getDataType(); + + // Dates and time + if (value instanceof PlcDATE && value.isDate()) + return RecordFieldType.DATE.getDataType(); + if (value instanceof PlcDATE_AND_TIME && value.isDateTime()) + return RecordFieldType.TIME.getDataType(); + if (value instanceof PlcTIME && value.isTime()) + return RecordFieldType.TIME.getDataType(); + if (value instanceof PlcTIME_OF_DAY && value.isTime()) + return RecordFieldType.TIME.getDataType(); + + // Everything else to string + return RecordFieldType.STRING.getDataType(); + } + + private static Object normalizeBasicTypes(final Object valueOriginal) { + if (valueOriginal == null) + return null; + + if (valueOriginal instanceof PlcValue) { + PlcValue value = (PlcValue) valueOriginal; + // 8 bits + if (value instanceof PlcBOOL && value.isBoolean()) + return value.getBoolean(); + if (value instanceof PlcBYTE && (value.isByte() || value.isShort())) + return new byte[]{value.getByte()}; + if (value instanceof PlcCHAR && value.isShort()) + return value.getString(); + if ((value instanceof PlcSINT || value instanceof PlcUSINT) && (value.isShort() || value.isInteger())) + return value.getShort(); + + + // 16 bits + if (value instanceof PlcWORD && (value.isInteger() || value.isShort())) + return value.getString(); + if (value instanceof PlcINT && value.isInteger()) + return value.getInteger(); + if (value instanceof PlcUINT && value.isInteger()) + return value.getInteger(); + if ((value instanceof PlcWCHAR || value instanceof PlcDWORD) && value.isInteger()) + return value.getString(); + + // 32 bits + if (value instanceof PlcREAL && value.isFloat()) + return value.getFloat(); + if ((value instanceof PlcDINT || value instanceof PlcUDINT) && value.isInteger()) + return value.getInteger(); + if (value instanceof PlcDWORD && value.isInteger()) + return value.getString(); + + // 64 bits + if ((value instanceof PlcLINT || value instanceof PlcULINT) && value.isLong()) + return value.getLong(); + if (value instanceof PlcLREAL && value.isDouble()) + return value.getDouble(); + if (value instanceof PlcLWORD && (value.isLong() || value.isBigInteger())) + return value.getString(); + + // Dates and time + if (value instanceof PlcDATE && value.isDate()) + return value.getDate(); + if (value instanceof PlcDATE_AND_TIME && value.isDateTime()) + return value.getDateTime(); + if (value instanceof PlcTIME && value.isTime()) + return value.getTime(); + if (value instanceof PlcTIME_OF_DAY && value.isTime()) + return value.getTime(); + + // Everything else to string + return value.getString(); + } + return valueOriginal; + } + + public static Object normalizeValue(final Object valueOriginal) { + if (valueOriginal == null) { + return null; + } + if (valueOriginal instanceof List) { + return ((List) valueOriginal).toArray(); + } else if (valueOriginal instanceof PlcValue) { + PlcValue value = (PlcValue) valueOriginal; + + if (value.isList() && value instanceof PlcList) { + Object[] r = new Object[value.getList().size()]; + int i = 0; + for (Object element : value.getList()) { + r[i] = normalizeBasicTypes(element); + i++; + } + return r; + } + return normalizeBasicTypes(value); + } else { + return valueOriginal; + } + } + +} + diff --git a/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor new file mode 100644 index 000000000..51b0b822f --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/main/resources/META-INF/services/org.apache.nifi.processor.Processor @@ -0,0 +1,19 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. +org.apache.plc4x.nifi.Plc4xSinkProcessor +org.apache.plc4x.nifi.Plc4xSinkRecordProcessor +org.apache.plc4x.nifi.Plc4xSourceProcessor +org.apache.plc4x.nifi.Plc4xSourceRecordProcessor +org.apache.plc4x.nifi.Plc4xListenRecordProcessor diff --git a/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xListenRecordProcessorTest.java b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xListenRecordProcessorTest.java new file mode 100644 index 000000000..8a5794e74 --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xListenRecordProcessorTest.java @@ -0,0 +1,81 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you 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. + */ +package org.apache.plc4x.nifi; + +import org.apache.nifi.avro.AvroRecordSetWriter; +import org.apache.nifi.reporting.InitializationException; +import org.apache.nifi.util.TestRunner; +import org.apache.nifi.util.TestRunners; +import org.apache.plc4x.nifi.address.AddressesAccessUtils; +import org.apache.plc4x.nifi.util.Plc4xCommonTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +public class Plc4xListenRecordProcessorTest { + + private TestRunner testRunner; + private static int NUMBER_OF_CALLS = 5; + + private final AvroRecordSetWriter writerService = new AvroRecordSetWriter(); + + @BeforeEach + public void init() throws InitializationException { + testRunner = TestRunners.newTestRunner(Plc4xListenRecordProcessor.class); + testRunner.setIncomingConnection(false); + testRunner.setValidateExpressionUsage(false); + + testRunner.setProperty(Plc4xListenRecordProcessor.PLC_FUTURE_TIMEOUT_MILISECONDS, "30000"); + testRunner.setProperty(Plc4xListenRecordProcessor.PLC_CONNECTION_STRING, "simulated://127.0.0.1"); + testRunner.setProperty(Plc4xListenRecordProcessor.PLC_SCHEMA_CACHE_SIZE, "1"); + + testRunner.addConnection(Plc4xListenRecordProcessor.REL_SUCCESS); + + testRunner.addControllerService("writer", writerService); + testRunner.enableControllerService(writerService); + testRunner.setProperty(Plc4xListenRecordProcessor.PLC_RECORD_WRITER_FACTORY.getName(), "writer"); + + Plc4xCommonTest.setLogger(testRunner.getLogger()); + } + + public void testAvroRecordWriterProcessor() throws InitializationException { + testRunner.run(NUMBER_OF_CALLS,true, true); + //validations + Plc4xCommonTest.assertContent(testRunner.getFlowFilesForRelationship(Plc4xSourceProcessor.REL_SUCCESS), false, true); + } + + // Test dynamic properties addressess access strategy + // Until simulated driver supports subscription + @Test + public void testWithAddressProperties() throws InitializationException { + testRunner.setProperty(AddressesAccessUtils.PLC_ADDRESS_ACCESS_STRATEGY, AddressesAccessUtils.ADDRESS_PROPERTY); + testAvroRecordWriterProcessor(); + } + + // Test addressess text property access strategy + // Until simulated driver supports subscription + @Test + public void testWithAddressText() throws InitializationException, JsonProcessingException { + testRunner.setProperty(AddressesAccessUtils.PLC_ADDRESS_ACCESS_STRATEGY, AddressesAccessUtils.ADDRESS_TEXT); + testRunner.setProperty(AddressesAccessUtils.ADDRESS_TEXT_PROPERTY, new ObjectMapper().writeValueAsString(Plc4xCommonTest.getAddressMap()).toString()); + testAvroRecordWriterProcessor(); + } +} diff --git a/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xNifiTest.java b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xNifiTest.java new file mode 100644 index 000000000..5c95490ff --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xNifiTest.java @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.nifi; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; + +import org.apache.plc4x.nifi.util.Plc4xCommonTest; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; + +import com.fasterxml.jackson.databind.ObjectMapper; + +public class Plc4xNifiTest { + + + private static File dumyAddressesFile; + + public static File getDumyAddressesFile() { + return dumyAddressesFile; + } + + @BeforeAll + public static void beforeAll() { + + dumyAddressesFile = new File("dumy-address-file"); + + try (FileWriter fileWriter = new FileWriter(dumyAddressesFile)) { + ObjectMapper mapper = new ObjectMapper(); + fileWriter.write(mapper.writeValueAsString(Plc4xCommonTest.getAddressMap())); + } catch (IOException e) { + e.printStackTrace(); + } + } + + @AfterAll + public static void afterAll() { + dumyAddressesFile.delete(); + } +} diff --git a/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSinkProcessorTest.java b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSinkProcessorTest.java new file mode 100644 index 000000000..352cbf091 --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSinkProcessorTest.java @@ -0,0 +1,97 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ +package org.apache.plc4x.nifi; + +import java.util.Map; +import java.util.stream.Collectors; + +import org.apache.nifi.reporting.InitializationException; +import org.apache.nifi.util.TestRunner; +import org.apache.nifi.util.TestRunners; +import org.apache.plc4x.nifi.address.AddressesAccessUtils; +import org.apache.plc4x.nifi.address.FilePropertyAccessStrategy; +import org.apache.plc4x.nifi.util.Plc4xCommonTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +public class Plc4xSinkProcessorTest extends Plc4xNifiTest{ + + private TestRunner testRunner; + private static final int NUMBER_OF_CALLS = 5; + + @BeforeEach + public void init() { + testRunner = TestRunners.newTestRunner(Plc4xSinkProcessor.class); + testRunner.setIncomingConnection(false); + testRunner.setValidateExpressionUsage(false); + + testRunner.setProperty(Plc4xSinkProcessor.PLC_CONNECTION_STRING, "simulated://127.0.0.1"); + testRunner.setProperty(Plc4xSinkProcessor.PLC_FUTURE_TIMEOUT_MILISECONDS, "1000"); + + testRunner.addConnection(Plc4xSinkProcessor.REL_SUCCESS); + testRunner.addConnection(Plc4xSinkProcessor.REL_FAILURE); + + for (int i = 0; i String.valueOf(e.getValue())))); + + Plc4xCommonTest.setLogger(testRunner.getLogger()); + } + + public void runProcessorTest() { + testRunner.run(NUMBER_OF_CALLS); + testRunner.assertTransferCount(Plc4xSinkProcessor.REL_FAILURE, 0); + testRunner.assertTransferCount(Plc4xSinkProcessor.REL_SUCCESS, NUMBER_OF_CALLS); + } + + // @Disabled + @Test + public void testWithAddressProperties() { + testRunner.setProperty(AddressesAccessUtils.PLC_ADDRESS_ACCESS_STRATEGY, AddressesAccessUtils.ADDRESS_PROPERTY); + Plc4xCommonTest.getAddressMap().forEach((k,v) -> testRunner.setProperty(k, v)); + runProcessorTest(); + } + + // Test addressess text property access strategy + // @Disabled + @Test + public void testWithAddressText() throws JsonProcessingException { + testRunner.setProperty(AddressesAccessUtils.PLC_ADDRESS_ACCESS_STRATEGY, AddressesAccessUtils.ADDRESS_TEXT); + testRunner.setProperty(AddressesAccessUtils.ADDRESS_TEXT_PROPERTY, new ObjectMapper().writeValueAsString(Plc4xCommonTest.getAddressMap())); + runProcessorTest(); + } + + // Test addressess file property access strategy + // @Disabled + @Test + public void testWithAddressFile() throws InitializationException { + testRunner.setProperty(AddressesAccessUtils.ADDRESS_FILE_PROPERTY, "file"); + + try (MockedStatic staticMock = Mockito.mockStatic(FilePropertyAccessStrategy.class)) { + staticMock.when(() -> FilePropertyAccessStrategy.extractAddressesFromFile("file")) + .thenReturn(Plc4xCommonTest.getAddressMap()); + + runProcessorTest(); + } + } + +} diff --git a/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSinkRecordProcessorTest.java b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSinkRecordProcessorTest.java new file mode 100644 index 000000000..d7166857e --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSinkRecordProcessorTest.java @@ -0,0 +1,101 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you 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. + */ +package org.apache.plc4x.nifi; + +import org.apache.nifi.json.JsonTreeReader; +import org.apache.nifi.reporting.InitializationException; +import org.apache.nifi.util.TestRunner; +import org.apache.nifi.util.TestRunners; +import org.apache.plc4x.nifi.address.AddressesAccessUtils; +import org.apache.plc4x.nifi.util.Plc4xCommonTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +public class Plc4xSinkRecordProcessorTest extends Plc4xNifiTest { + + private TestRunner testRunner; + private static final int NUMBER_OF_CALLS = 5; + + private final JsonTreeReader readerService = new JsonTreeReader(); + + @BeforeEach + public void init() throws InitializationException, JsonProcessingException { + testRunner = TestRunners.newTestRunner(Plc4xSinkRecordProcessor.class); + testRunner.setIncomingConnection(false); + testRunner.setValidateExpressionUsage(false); + + testRunner.setProperty(Plc4xSinkRecordProcessor.PLC_CONNECTION_STRING, "simulated://127.0.0.1"); + testRunner.setProperty(Plc4xSinkRecordProcessor.PLC_FUTURE_TIMEOUT_MILISECONDS, "1000"); + + testRunner.addConnection(Plc4xSinkRecordProcessor.REL_SUCCESS); + testRunner.addConnection(Plc4xSinkRecordProcessor.REL_FAILURE); + + testRunner.addControllerService("reader", readerService); + testRunner.enableControllerService(readerService); + testRunner.setProperty(Plc4xSinkRecordProcessor.PLC_RECORD_READER_FACTORY.getName(), "reader"); + + for (int i = 0; i testRunner.setProperty(k, v)); + testProcessor(); + } + + // Test addressess text property access strategy + // @Disabled + @Test + public void testWithAddressText() throws JsonProcessingException { + testRunner.setProperty(AddressesAccessUtils.PLC_ADDRESS_ACCESS_STRATEGY, AddressesAccessUtils.ADDRESS_TEXT); + testRunner.setProperty(AddressesAccessUtils.ADDRESS_TEXT_PROPERTY, new ObjectMapper().writeValueAsString(Plc4xCommonTest.getAddressMap()).toString()); + testProcessor(); + } + + // Test addressess file property access strategy + // @Disabled + @Test + public void testWithAdderessFile() { + testRunner.setProperty(AddressesAccessUtils.PLC_ADDRESS_ACCESS_STRATEGY, AddressesAccessUtils.ADDRESS_FILE); + testRunner.setProperty(AddressesAccessUtils.ADDRESS_FILE_PROPERTY, getDumyAddressesFile().getAbsolutePath()); + + // This is needed as we access the file name property to check if it has EL to mark it for caching + // And that triggers a failure even if the property is not used then. EL is evaluated later. + testRunner.setValidateExpressionUsage(false); + testProcessor(); + } +} diff --git a/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSourceProcessorTest.java b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSourceProcessorTest.java new file mode 100644 index 000000000..148e705af --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSourceProcessorTest.java @@ -0,0 +1,89 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.nifi; + +import java.lang.reflect.InvocationTargetException; + +import org.apache.nifi.util.TestRunner; +import org.apache.nifi.util.TestRunners; +import org.apache.plc4x.nifi.address.AddressesAccessUtils; +import org.apache.plc4x.nifi.util.Plc4xCommonTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +public class Plc4xSourceProcessorTest extends Plc4xNifiTest { + + private TestRunner testRunner; + private static final int NUMBER_OF_CALLS = 5; + + @BeforeEach + public void init() throws IllegalAccessException, IllegalArgumentException, InvocationTargetException, NoSuchMethodException, SecurityException { + testRunner = TestRunners.newTestRunner(Plc4xSourceProcessor.class); + testRunner.setIncomingConnection(false); + testRunner.setValidateExpressionUsage(true); + + testRunner.setEnvironmentVariableValue("url", "simulated://127.0.0.1"); + + testRunner.setProperty(Plc4xSourceProcessor.PLC_CONNECTION_STRING, "${url}"); + testRunner.setProperty(Plc4xSourceProcessor.PLC_FUTURE_TIMEOUT_MILISECONDS, "1000"); + + testRunner.addConnection(Plc4xSourceProcessor.REL_SUCCESS); + testRunner.addConnection(Plc4xSourceProcessor.REL_FAILURE); + Plc4xCommonTest.setLogger(testRunner.getLogger()); + } + + public void testProcessor() { + + testRunner.run(NUMBER_OF_CALLS); + testRunner.assertTransferCount(Plc4xSourceProcessor.REL_FAILURE, 0); + testRunner.assertTransferCount(Plc4xSourceProcessor.REL_SUCCESS, NUMBER_OF_CALLS); + } + + // Test dynamic properties addressess access strategy + @Test + public void testWithAddressProperties() { + testRunner.setProperty(AddressesAccessUtils.PLC_ADDRESS_ACCESS_STRATEGY, AddressesAccessUtils.ADDRESS_PROPERTY); + Plc4xCommonTest.getAddressMap().forEach((k,v) -> testRunner.setProperty(k, v)); + testProcessor(); + } + + // Test addressess text property access strategy + @Test + public void testWithAddressText() throws JsonProcessingException { + testRunner.setProperty(AddressesAccessUtils.PLC_ADDRESS_ACCESS_STRATEGY, AddressesAccessUtils.ADDRESS_TEXT); + testRunner.setProperty(AddressesAccessUtils.ADDRESS_TEXT_PROPERTY, new ObjectMapper().writeValueAsString(Plc4xCommonTest.getAddressMap()).toString()); + testProcessor(); + } + + // Test addressess file property access strategy + @Test + public void testWithAdderessFile() { + testRunner.setProperty(AddressesAccessUtils.PLC_ADDRESS_ACCESS_STRATEGY, AddressesAccessUtils.ADDRESS_FILE); + testRunner.setProperty(AddressesAccessUtils.ADDRESS_FILE_PROPERTY, getDumyAddressesFile().getAbsolutePath()); + + // This is needed as we access the file name property to check if it has EL to mark it for caching + // And that triggers a failure even if the property is not used then. EL is evaluated later. + testRunner.setValidateExpressionUsage(false); + + testProcessor(); + } +} diff --git a/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSourceRecordProcessorTest.java b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSourceRecordProcessorTest.java new file mode 100644 index 000000000..784dbfce4 --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/test/java/org/apache/plc4x/nifi/Plc4xSourceRecordProcessorTest.java @@ -0,0 +1,95 @@ +/* + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you 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. + */ +package org.apache.plc4x.nifi; + +import org.apache.nifi.json.JsonRecordSetWriter; +import org.apache.nifi.reporting.InitializationException; +import org.apache.nifi.util.TestRunner; +import org.apache.nifi.util.TestRunners; +import org.apache.plc4x.nifi.address.AddressesAccessUtils; +import org.apache.plc4x.nifi.util.Plc4xCommonTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +public class Plc4xSourceRecordProcessorTest extends Plc4xNifiTest { + + private TestRunner testRunner; + private static final int NUMBER_OF_CALLS = 5; + private final JsonRecordSetWriter writerService = new JsonRecordSetWriter(); + + @BeforeEach + public void init() throws InitializationException { + testRunner = TestRunners.newTestRunner(Plc4xSourceRecordProcessor.class); + testRunner.setIncomingConnection(false); + testRunner.setValidateExpressionUsage(false); + + testRunner.setProperty(Plc4xSourceRecordProcessor.PLC_FUTURE_TIMEOUT_MILISECONDS, "100"); + testRunner.setProperty(Plc4xSourceRecordProcessor.PLC_CONNECTION_STRING, "simulated://127.0.0.1"); + testRunner.setProperty(Plc4xSourceRecordProcessor.PLC_SCHEMA_CACHE_SIZE, "1"); + + testRunner.addConnection(Plc4xSourceRecordProcessor.REL_SUCCESS); + testRunner.addConnection(Plc4xSourceRecordProcessor.REL_FAILURE); + + testRunner.addControllerService("writer", writerService); + testRunner.enableControllerService(writerService); + testRunner.setProperty(Plc4xSourceRecordProcessor.PLC_RECORD_WRITER_FACTORY.getName(), "writer"); + + Plc4xCommonTest.setLogger(testRunner.getLogger()); + } + + public void testProcessor() { + testRunner.run(NUMBER_OF_CALLS,true, true); + //validations + testRunner.assertTransferCount(Plc4xSourceRecordProcessor.REL_FAILURE, 0); + testRunner.assertTransferCount(Plc4xSourceRecordProcessor.REL_SUCCESS, NUMBER_OF_CALLS); + + Plc4xCommonTest.assertContent(testRunner.getFlowFilesForRelationship(Plc4xSourceRecordProcessor.REL_SUCCESS), false, true); + } + + // Test dynamic properties addressess access strategy + @Test + public void testWithAddressProperties() { + testRunner.setProperty(AddressesAccessUtils.PLC_ADDRESS_ACCESS_STRATEGY, AddressesAccessUtils.ADDRESS_PROPERTY); + Plc4xCommonTest.getAddressMap().forEach((k,v) -> testRunner.setProperty(k, v)); + testProcessor(); + } + + // Test addressess text property access strategy + @Test + public void testWithAddressText() throws JsonProcessingException { + testRunner.setProperty(AddressesAccessUtils.PLC_ADDRESS_ACCESS_STRATEGY, AddressesAccessUtils.ADDRESS_TEXT); + testRunner.setProperty(AddressesAccessUtils.ADDRESS_TEXT_PROPERTY, new ObjectMapper().writeValueAsString(Plc4xCommonTest.getAddressMap()).toString()); + testProcessor(); + } + + // Test addressess file property access strategy + @Test + public void testWithAdderessFile() { + testRunner.setProperty(AddressesAccessUtils.PLC_ADDRESS_ACCESS_STRATEGY, AddressesAccessUtils.ADDRESS_FILE); + testRunner.setProperty(AddressesAccessUtils.ADDRESS_FILE_PROPERTY, getDumyAddressesFile().getAbsolutePath()); + + // This is needed as we access the file name property to check if it has EL to mark it for caching + // And that triggers a failure even if the property is not used then. EL is evaluated later. + testRunner.setValidateExpressionUsage(false); + testProcessor(); + } +} diff --git a/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/test/java/org/apache/plc4x/nifi/address/AccessStrategyTest.java b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/test/java/org/apache/plc4x/nifi/address/AccessStrategyTest.java new file mode 100644 index 000000000..3941e0483 --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/test/java/org/apache/plc4x/nifi/address/AccessStrategyTest.java @@ -0,0 +1,216 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ +package org.apache.plc4x.nifi.address; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.Map; + +import org.apache.nifi.flowfile.FlowFile; +import org.apache.nifi.util.TestRunner; +import org.apache.nifi.util.TestRunners; +import org.apache.plc4x.nifi.Plc4xNifiTest; +import org.apache.plc4x.nifi.Plc4xSourceProcessor; +import org.apache.plc4x.nifi.util.Plc4xCommonTest; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +public class AccessStrategyTest extends Plc4xNifiTest { + + @Mock + FilePropertyAccessStrategy testFileObject = new FilePropertyAccessStrategy(); + + private TestRunner testRunner; + + // Tests that addresses in dynamic properties are read correctly and addresses are cached if no EL is used + @Test + public void testDynamicPropertyAccessStrategy() { + + DynamicPropertyAccessStrategy testObject = new DynamicPropertyAccessStrategy(); + testRunner = TestRunners.newTestRunner(Plc4xSourceProcessor.class); + + assert testObject.getAllowableValue().equals(AddressesAccessUtils.ADDRESS_PROPERTY); + assert testObject.getPropertyDescriptors().isEmpty(); + + Plc4xCommonTest.getAddressMap().forEach((k,v) -> testRunner.setProperty(k, v)); + + FlowFile flowFile = testRunner.enqueue(""); + + Map values = testObject.extractAddresses(testRunner.getProcessContext(), flowFile); + + assertTrue(testObject.getCachedAddresses().equals(values)); + assertTrue(testObject.getCachedAddresses().equals(Plc4xCommonTest.getAddressMap())); + } + + // Tests incorrect address detection on dynamic properties + @Test + public void testDynamicPropertyAccessStrategyIncorrect() { + testRunner = TestRunners.newTestRunner(Plc4xSourceProcessor.class); + + Plc4xCommonTest.getAddressMap().forEach((k,v) -> testRunner.setProperty(k, "no an correct address")); + + testRunner.assertNotValid(); + } + + // Tests that if EL is present in dynamic properties the processor is valid + @Test + public void testDynamicPropertyAccessStrategyELPresent() { + testRunner = TestRunners.newTestRunner(Plc4xSourceProcessor.class); + testRunner.setProperty(Plc4xSourceProcessor.PLC_CONNECTION_STRING, "simulated://127.0.0.1"); + + Plc4xCommonTest.getAddressMap().forEach((k,v) -> testRunner.setProperty(k, "${attribute}")); + + testRunner.assertValid(); + } + + // Tests that addresses in text property are read correctly and addresses are cached if no EL is used + @Test + public void testTextPropertyAccessStrategy() throws JsonProcessingException { + + TextPropertyAccessStrategy testObject = new TextPropertyAccessStrategy(); + testRunner = TestRunners.newTestRunner(Plc4xSourceProcessor.class); + + assert testObject.getAllowableValue().equals(AddressesAccessUtils.ADDRESS_TEXT); + assert testObject.getPropertyDescriptors().contains(AddressesAccessUtils.ADDRESS_TEXT_PROPERTY); + + + testRunner.setProperty(AddressesAccessUtils.PLC_ADDRESS_ACCESS_STRATEGY, AddressesAccessUtils.ADDRESS_TEXT); + testRunner.setProperty(AddressesAccessUtils.ADDRESS_TEXT_PROPERTY, new ObjectMapper().writeValueAsString(Plc4xCommonTest.getAddressMap()).toString()); + + FlowFile flowFile = testRunner.enqueue(""); + + Map values = testObject.extractAddresses(testRunner.getProcessContext(), flowFile); + + assertTrue(testObject.getCachedAddresses().equals(values)); + assertTrue(testObject.getCachedAddresses().equals(Plc4xCommonTest.getAddressMap())); + } + + + + // Tests incorrect address detection on text property + @Test + public void testTextPropertyAccessStrategyIncorrect() { + + TextPropertyAccessStrategy testObject = new TextPropertyAccessStrategy(); + testRunner = TestRunners.newTestRunner(Plc4xSourceProcessor.class); + + assert testObject.getAllowableValue().equals(AddressesAccessUtils.ADDRESS_TEXT); + assert testObject.getPropertyDescriptors().contains(AddressesAccessUtils.ADDRESS_TEXT_PROPERTY); + + + testRunner.setProperty(AddressesAccessUtils.PLC_ADDRESS_ACCESS_STRATEGY, AddressesAccessUtils.ADDRESS_TEXT); + + Plc4xCommonTest.getAddressMap().forEach((k,v) -> testRunner.setProperty(AddressesAccessUtils.ADDRESS_TEXT_PROPERTY.getName(), "no an correct address")); + + testRunner.assertNotValid(); + + Plc4xCommonTest.getAddressMap().forEach((k,v) -> testRunner.setProperty(AddressesAccessUtils.ADDRESS_TEXT_PROPERTY.getName(), "{\"neither\":\"this one\"}")); + + testRunner.assertNotValid(); + } + + // Tests that if EL is present in text property the processor is valid + @Test + public void testTextPropertyAccessStrategyELPresent() { + + TextPropertyAccessStrategy testObject = new TextPropertyAccessStrategy(); + testRunner = TestRunners.newTestRunner(Plc4xSourceProcessor.class); + + testRunner.setProperty(Plc4xSourceProcessor.PLC_CONNECTION_STRING, "simulated://127.0.0.1"); + + assert testObject.getAllowableValue().equals(AddressesAccessUtils.ADDRESS_TEXT); + assert testObject.getPropertyDescriptors().contains(AddressesAccessUtils.ADDRESS_TEXT_PROPERTY); + + testRunner.setProperty(AddressesAccessUtils.PLC_ADDRESS_ACCESS_STRATEGY, AddressesAccessUtils.ADDRESS_TEXT); + + Plc4xCommonTest.getAddressMap().forEach((k,v) -> testRunner.setProperty(AddressesAccessUtils.ADDRESS_TEXT_PROPERTY.getName(), "${attribute}")); + + testRunner.assertValid(); + } + + // Tests that addresses in file are read correctly and addresses are cached if no EL is used + @Test + public void testFilePropertyAccessStrategy() throws IOException { + + testRunner = TestRunners.newTestRunner(Plc4xSourceProcessor.class); + + assert testFileObject.getAllowableValue().equals(AddressesAccessUtils.ADDRESS_FILE); + assert testFileObject.getPropertyDescriptors().contains(AddressesAccessUtils.ADDRESS_FILE_PROPERTY); + + + testRunner.setProperty(AddressesAccessUtils.PLC_ADDRESS_ACCESS_STRATEGY, AddressesAccessUtils.ADDRESS_FILE); + testRunner.setProperty(AddressesAccessUtils.ADDRESS_FILE_PROPERTY, "file"); + + try (MockedStatic staticMock = Mockito.mockStatic(FilePropertyAccessStrategy.class)) { + staticMock.when(() -> FilePropertyAccessStrategy.extractAddressesFromFile("file")) + .thenReturn(Plc4xCommonTest.getAddressMap()); + + + FlowFile flowFile = testRunner.enqueue(""); + Map values = testFileObject.extractAddresses(testRunner.getProcessContext(), flowFile); + + assertTrue(testFileObject.getCachedAddresses().equals(values)); + assertTrue(testFileObject.getCachedAddresses().equals(Plc4xCommonTest.getAddressMap())); + } + } + + // Tests incorrect address detection on file + @Test + public void testFilePropertyAccessStrategyIncorrect() throws IOException { + + testRunner = TestRunners.newTestRunner(Plc4xSourceProcessor.class); + + assert testFileObject.getAllowableValue().equals(AddressesAccessUtils.ADDRESS_FILE); + assert testFileObject.getPropertyDescriptors().contains(AddressesAccessUtils.ADDRESS_FILE_PROPERTY); + + + testRunner.setProperty(AddressesAccessUtils.PLC_ADDRESS_ACCESS_STRATEGY, AddressesAccessUtils.ADDRESS_FILE); + testRunner.setProperty(AddressesAccessUtils.ADDRESS_FILE_PROPERTY, "file"); + + try (MockedStatic staticMock = Mockito.mockStatic(FilePropertyAccessStrategy.class)) { + staticMock.when(() -> FilePropertyAccessStrategy.extractAddressesFromFile("file")) + .thenReturn(Map.of("not", "a correct address")); + + testRunner.assertNotValid(); + } + } + + // Tests that if EL is present in file the processor is valid + @Test + public void testFilePropertyAccessStrategyELPresent() throws IOException { + + testRunner = TestRunners.newTestRunner(Plc4xSourceProcessor.class); + + testRunner.setProperty(Plc4xSourceProcessor.PLC_CONNECTION_STRING, "simulated://127.0.0.1"); + + assert testFileObject.getAllowableValue().equals(AddressesAccessUtils.ADDRESS_FILE); + assert testFileObject.getPropertyDescriptors().contains(AddressesAccessUtils.ADDRESS_FILE_PROPERTY); + + + testRunner.setProperty(AddressesAccessUtils.PLC_ADDRESS_ACCESS_STRATEGY, AddressesAccessUtils.ADDRESS_FILE); + testRunner.setProperty(AddressesAccessUtils.ADDRESS_FILE_PROPERTY, Plc4xNifiTest.getDumyAddressesFile().getAbsolutePath()); + + + testRunner.assertValid(); + } +} diff --git a/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/test/java/org/apache/plc4x/nifi/record/SchemaCacheTest.java b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/test/java/org/apache/plc4x/nifi/record/SchemaCacheTest.java new file mode 100644 index 000000000..8584a3455 --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/test/java/org/apache/plc4x/nifi/record/SchemaCacheTest.java @@ -0,0 +1,174 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +package org.apache.plc4x.nifi.record; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; + +import org.apache.avro.Schema; +import org.apache.avro.SchemaBuilder; +import org.apache.avro.SchemaBuilder.FieldAssembler; +import org.apache.nifi.avro.AvroTypeUtil; +import org.apache.nifi.serialization.record.RecordSchema; +import org.apache.plc4x.java.api.model.PlcTag; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class SchemaCacheTest { + + private static final SchemaCache schemaCache = new SchemaCache(0); + private static final List schemas = new ArrayList<>(); + private static final List> tags = new ArrayList<>(); + private static final List> addresses = new ArrayList<>(); + private static final List> tagNames = new ArrayList<>(); + + @BeforeAll + static public void innit() { + // Create 5 unique Schemas + + for (int i = 0; i < 5; i++) { + FieldAssembler fieldAssembler = SchemaBuilder.builder().record("foo").fields(); + Collection collection = new HashSet<>(); + List innerTags = new ArrayList<>(); + String address = null; + + switch (i) { + case 0: + address = "RANDOM/fooBOOL:BOOL"; + addresses.add(Map.of("BOOL", address)); + innerTags.add(new TestPlcTag(address)); + tags.add(innerTags); + fieldAssembler.nullableBoolean("BOOL", false); + collection.add("fooBOOL"); + tagNames.add(new LinkedHashSet<>(collection)); + break; + case 1: + address = "RANDOM/fooINT:INT"; + addresses.add(Map.of("INT", address)); + innerTags.add(new TestPlcTag(address)); + tags.add(innerTags); + fieldAssembler.nullableInt("INT", 1); + collection.add("fooINT"); + tagNames.add(new LinkedHashSet<>(collection)); + break; + case 2: + address = "RANDOM/fooLONG:LONG"; + addresses.add(Map.of("LONG", address)); + innerTags.add(new TestPlcTag(address)); + tags.add(innerTags); + fieldAssembler.nullableLong("LONG", i * 100); + collection.add("fooLONG"); + tagNames.add(new LinkedHashSet<>(collection)); + break; + case 3: + address = "RANDOM/fooFLOAT:FLOAT"; + addresses.add(Map.of("FLOAT", address)); + innerTags.add(new TestPlcTag(address)); + tags.add(innerTags); + fieldAssembler.nullableFloat("FLOAT", i * 0.1F); + collection.add("fooFLOAT"); + tagNames.add(new LinkedHashSet<>(collection)); + break; + case 4: + address = "RANDOM/fooDOUBLE:DOUBLE"; + addresses.add(Map.of("DOUBLE", address)); + innerTags.add(new TestPlcTag(address)); + tags.add(innerTags); + fieldAssembler.nullableDouble("DOUBLE", i * 0.01); + collection.add("fooDOUBLE"); + tagNames.add(new LinkedHashSet<>(collection)); + break; + } + Schema avroSchema = fieldAssembler.endRecord(); + schemas.add(AvroTypeUtil.createSchema(avroSchema)); + } + } + + // Cache size set to 4 < number of schemas: to check schema override. + @BeforeEach + public void testCacheSize() { + schemaCache.restartCache(4); + assert schemaCache.getCacheSize() == 4; + assert schemaCache.getNextSchemaPosition() == 0; + } + + // In this test we add 4 schemas and try to add schema 0 again. It should not be added. + @Test + public void testAddSchema() { + for (int i = 0; i < 4; i++) { + schemaCache.addSchema(addresses.get(i), tagNames.get(i), tags.get(i), schemas.get(i)); + assert schemaCache.getNextSchemaPosition() == i + 1; + } + int prev = schemaCache.getNextSchemaPosition(); + schemaCache.addSchema(addresses.get(0), tagNames.get(0), tags.get(0), schemas.get(0)); + assert prev == schemaCache.getNextSchemaPosition(); + } + + // In this test check schema overriding + @Test + public void testSchemaOverride() { + for (int i = 0; i < 4; i++) { + schemaCache.addSchema(addresses.get(i), tagNames.get(i), tags.get(i), schemas.get(i)); + assert schemaCache.getNextSchemaPosition() == i + 1; + } + // Override first schema + schemaCache.addSchema(addresses.get(4), tagNames.get(4), tags.get(4), schemas.get(4)); + assert schemaCache.getNextSchemaPosition() == 1; + + // First schema should not be present in the cache + assert schemaCache.retrieveSchema(addresses.get(0)) == null; + + // Check remaining schemas + for (int i=1; i<5; i++){ + assert schemaCache.retrieveSchema(addresses.get(i)) == schemas.get(i); + } + } + + + public static void main(String[] args) { + SchemaCacheTest.innit(); + + SchemaCacheTest instance = new SchemaCacheTest(); + + instance.testCacheSize(); + instance.testAddSchema(); + + instance.testCacheSize(); + instance.testSchemaOverride(); + } + + + private static class TestPlcTag implements PlcTag { + + final String address; + + TestPlcTag(String address) { + this.address = address; + } + + @Override + public String getAddressString() { + return address; + } + } +} diff --git a/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/test/java/org/apache/plc4x/nifi/util/Plc4xCommonTest.java b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/test/java/org/apache/plc4x/nifi/util/Plc4xCommonTest.java new file mode 100644 index 000000000..997549702 --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/test/java/org/apache/plc4x/nifi/util/Plc4xCommonTest.java @@ -0,0 +1,210 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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. + */ + +package org.apache.plc4x.nifi.util; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.nifi.json.JsonParserFactory; +import org.apache.nifi.json.JsonTreeRowRecordReader; +import org.apache.nifi.serialization.MalformedRecordException; +import org.apache.nifi.serialization.SimpleRecordSchema; +import org.apache.nifi.serialization.record.MapRecord; +import org.apache.nifi.serialization.record.Record; +import org.apache.nifi.serialization.record.RecordField; +import org.apache.nifi.serialization.record.RecordFieldType; +import org.apache.nifi.serialization.record.RecordSchema; +import org.apache.nifi.util.MockComponentLog; +import org.apache.nifi.util.MockFlowFile; + +public class Plc4xCommonTest { + // public static final Logger internalLogger = LoggerFactory.getLogger(Plc4xCommonTest.class); + public static MockComponentLog logger; + + public static final Map originalMap = new HashMap<>(); + public static final Map addressMap = new HashMap<>(); + public static final Map> typeMap = new HashMap<>(); + + + // TODO: BOOL, WORD; DWORD and LWORD are commented because random generation is not working with this types + // or a because a reverse type mapping between avro and PlcTypes is not implemented + public static final RecordSchema schema; + + + static { + List recordFields = new ArrayList<>(); + + // recordFields.add(new RecordField(null, null, recordFields)) + recordFields.add(new RecordField("BOOL", RecordFieldType.BOOLEAN.getDataType(), true)); + // recordFields.add(new RecordField("BYTE", RecordFieldType.SHORT.getDataType(), new byte[] {1,2}) Bytes + // recordFields.add(new RecordField("WORD", "4") String + recordFields.add(new RecordField("SINT", RecordFieldType.SHORT.getDataType(), -5)); + recordFields.add(new RecordField("USINT", RecordFieldType.SHORT.getDataType(), "6")); + recordFields.add(new RecordField("INT", RecordFieldType.ARRAY.getArrayDataType(RecordFieldType.INT.getDataType()))); + recordFields.add(new RecordField("UINT", RecordFieldType.INT.getDataType(), "3000")); + recordFields.add(new RecordField("DINT", RecordFieldType.INT.getDataType(), "4000")); + recordFields.add(new RecordField("UDINT", RecordFieldType.LONG.getDataType(), "5000")); + // recordFields.add(new RecordField("DWORD", RecordFieldType.BOOLEAN.getDataType(), "0")); + // recordFields.add(new RecordField("LI.NT", RecordFieldType.BOOLEAN.getDataType(), 6000L)); + recordFields.add(new RecordField("ULINT", RecordFieldType.BIGINT.getDataType(), "7000")); + // recordFields.add(new RecordField("LWORD", RecordFieldType.BOOLEAN.getDataType(), "0")); + recordFields.add(new RecordField("REAL", RecordFieldType.FLOAT.getDataType(), 1.23456F)); + recordFields.add(new RecordField("LREAL", RecordFieldType.DOUBLE.getDataType(), 2.34567)); + recordFields.add(new RecordField("CHAR", RecordFieldType.STRING.getDataType(), "c")); + recordFields.add(new RecordField("WCHAR", RecordFieldType.STRING.getDataType(), "d")); + recordFields.add(new RecordField("STRING", RecordFieldType.STRING.getDataType(), "this is a string")); + + schema = new SimpleRecordSchema(recordFields); + + + // originalMap values are in the type needed to check type mapping between PlcType and Avro + originalMap.put("BOOL", true); + originalMap.put("BYTE", (byte) 3); + originalMap.put("WORD", "4"); + originalMap.put("SINT", -5); + originalMap.put("USINT", "6"); + originalMap.put("INT", new int[]{2000, 3000, 4000, 5000}); + originalMap.put("UINT", "3000"); + originalMap.put("DINT", "4000"); + originalMap.put("UDINT", "5000"); + originalMap.put("DWORD", Long.valueOf("0")); + originalMap.put("LI.NT", 6000L); + originalMap.put("ULINT", "7000"); + originalMap.put("LWORD", Long.valueOf("0")); + originalMap.put("REAL", 1.23456F); + originalMap.put("LREAL", 2.34567); + originalMap.put("CHAR", "c"); + originalMap.put("WCHAR", "d"); + originalMap.put("STRING", "this is a string"); + + addressMap.put("BOOL", "RANDOM/v1:BOOL"); + addressMap.put("BYTE", "RANDOM/v2:BYTE"); + addressMap.put("WORD", "RANDOM/v3:WORD"); + addressMap.put("SINT", "RANDOM/v4:SINT"); + addressMap.put("USINT", "RANDOM/v5:USINT"); + addressMap.put("INT", "RANDOM/v6:INT"); + addressMap.put("UINT", "RANDOM/v7:UINT"); + addressMap.put("DINT", "RANDOM/v8:DINT"); + addressMap.put("UDINT", "RANDOM/v9:UDINT"); + addressMap.put("DWORD", "RANDOM/v10:DWORD"); + addressMap.put("LI.NT", "RANDOM/v11:LINT"); + addressMap.put("ULINT", "RANDOM/v12:ULINT"); + addressMap.put("LWORD", "RANDOM/v13:LWORD"); + addressMap.put("REAL", "RANDOM/v14:REAL"); + addressMap.put("LREAL", "RANDOM/v15:LREAL"); + addressMap.put("CHAR", "RANDOM/v16:CHAR"); + addressMap.put("WCHAR", "RANDOM/v17:WCHAR"); + addressMap.put("STRING", "RANDOM/v18:STRING"); + + typeMap.put("BOOL", Boolean.class); + typeMap.put("BYTE", Short.class); + typeMap.put("WORD", String.class); + typeMap.put("SINT", Short.class); + typeMap.put("USINT", Short.class); + typeMap.put("INT", Integer.class); + typeMap.put("UINT", Integer.class); + typeMap.put("DINT", Integer.class); + typeMap.put("UDINT", Long.class); + typeMap.put("DWORD", String.class); + typeMap.put("LI.NT", Long.class); + typeMap.put("ULINT", BigInteger.class); + typeMap.put("LWORD", String.class); + typeMap.put("REAL", Float.class); + typeMap.put("LREAL", Double.class); + typeMap.put("CHAR", String.class); + typeMap.put("WCHAR", String.class); + typeMap.put("STRING", String.class); + + } + + public static Map getAddressMap(){ + Map result = new HashMap<>(); + + addressMap.forEach((k,v) -> { + if (v.startsWith("RANDOM/")) { + if (!v.endsWith("BYTE") && + !v.endsWith("CHAR")) + result.put(k, v); + } else { + result.put(k, v); + } + + }); + return result; + } + + public static void assertContent(List flowfiles, boolean checkValue, boolean checkType) { + flowfiles.forEach(t -> { + + try (InputStream stream = new ByteArrayInputStream(t.getContent().getBytes(StandardCharsets.UTF_8))) { + try (JsonTreeRowRecordReader reader = new JsonTreeRowRecordReader(stream, logger, schema, null, null, null, null, null, null, null, new JsonParserFactory())) { + Record record = reader.nextRecord(); + + while (record!=null) { + for (String tag : Plc4xCommonTest.addressMap.keySet()) { + + Object value = record.getValue(tag); + + if (value != null) { + // Check value after string conversion + if (checkValue) { + logger.info("{} Checking type: {} =? {}", tag, value, Plc4xCommonTest.originalMap.get(tag)); + assert value.toString().equalsIgnoreCase(Plc4xCommonTest.originalMap.get(tag).toString()); + } + + // Check type + if (checkType) { + if (!(value instanceof Object[])) { + logger.info("{} Checking type: {} ({}) =? {}", tag, value.getClass(), value, Plc4xCommonTest.typeMap.get(tag)); + assert value.getClass().equals(Plc4xCommonTest.typeMap.get(tag)); + } else { + logger.info("{} Checking List type: {} ({}) =? {}", tag, ((Object[]) value)[0].getClass(), value, Plc4xCommonTest.typeMap.get(tag)); + assert ((Object[]) value)[0].getClass().equals(Plc4xCommonTest.typeMap.get(tag)); + } + } + } + } + record = reader.nextRecord(); + } + } catch (IOException | MalformedRecordException e) { + e.printStackTrace(); + } + } catch (IOException e) { + e.printStackTrace();; + } + + }); + } + + public static Record getTestRecord() { + Record record = new MapRecord(schema, originalMap); + return record; + } + + + public static void setLogger(MockComponentLog logg) { + logger = logg; + } +} diff --git a/plc4j/tools/ui/application/src/main/resources/logback.xml b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/test/resources/logback-test.xml similarity index 84% rename from plc4j/tools/ui/application/src/main/resources/logback.xml rename to plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/test/resources/logback-test.xml index 22a5cdfee..2b9cea25d 100644 --- a/plc4j/tools/ui/application/src/main/resources/logback.xml +++ b/plc4j/integrations/apache-nifi/nifi-2/nifi-2-plc4x-processors/src/test/resources/logback-test.xml @@ -25,14 +25,11 @@ - [%thread] %highlight(%-5level) %cyan(%logger{15}) - %msg %n + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n - - - - + diff --git a/plc4j/integrations/apache-nifi/nifi-2/pom.xml b/plc4j/integrations/apache-nifi/nifi-2/pom.xml new file mode 100644 index 000000000..8e19f83f1 --- /dev/null +++ b/plc4j/integrations/apache-nifi/nifi-2/pom.xml @@ -0,0 +1,150 @@ + + + + + + org.apache.plc4x.extras + plc4j-apache-nifi + 1.0.0-SNAPSHOT + + + plc4j-apache-nifi-2 + pom + + PLC4J: Integrations: Apache Nifi 2 + Integration module for integrating PLC4X into Apache Nifi 2. + + + + 2026-09-20T15:22:10Z + 21 + 21 + 21 + 21 + 2.5.0 + 2.3.0 + + + + + + + + + org.apache.nifi + nifi-nar-maven-plugin + ${nifi.nar-plugin.version} + true + + + + org.codehaus.mojo + buildnumber-maven-plugin + true + + + validate + + create + + + + + false + false + 7 + true + + buildRevision + buildBranch + + + + + + + + + + org.junit + junit-bom + 6.1.1 + pom + import + + + net.bytebuddy + byte-buddy + 1.18.11 + + + org.apache.nifi + nifi + ${nifi.version} + pom + import + + + org.apache.nifi + nifi-utils + ${nifi.version} + + + org.apache.nifi + nifi-json-utils + ${nifi.version} + + + org.apache.nifi + nifi-record-serialization-services + ${nifi.version} + + + org.apache.nifi + nifi-record + ${nifi.version} + + + org.apache.nifi + nifi-record-serialization-service-api + ${nifi.version} + + + org.apache.nifi + nifi-schema-registry-service-api + ${nifi.version} + test + + + org.apache.nifi + nifi-mock + ${nifi.version} + test + + + org.apache.nifi + nifi-mock-record-utils + ${nifi.version} + test + + + + + \ No newline at end of file diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/pom.xml b/plc4j/integrations/apache-nifi/nifi-plc4x-processors/pom.xml deleted file mode 100644 index c81d0d8e6..000000000 --- a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/pom.xml +++ /dev/null @@ -1,191 +0,0 @@ - - - - 4.0.0 - - - org.apache.plc4x - plc4j-apache-nifi - 0.13.0-SNAPSHOT - - - plc4j-nifi-plc4x-processors - - PLC4J: Integrations: Apache Nifi: Processors - - - 2024-02-16T14:53:02Z - - - - - org.apache.plc4x - plc4j-api - 0.13.0-SNAPSHOT - - - org.apache.plc4x - plc4j-connection-cache - 0.13.0-SNAPSHOT - - - org.apache.plc4x - plc4j-spi - 0.13.0-SNAPSHOT - - - org.apache.nifi - nifi-api - - - org.apache.nifi - nifi-record - - - org.apache.nifi - nifi-record-serialization-service-api - - - org.apache.nifi - nifi-utils - - - org.apache.nifi - nifi-json-utils - - - org.apache.nifi - nifi-avro-record-utils - - - org.apache.avro - avro - - - - org.apache.nifi - nifi-record-serialization-services - test - - - javax.validation - validation-api - - - - - com.fasterxml.jackson.core - jackson-core - - - com.fasterxml.jackson.core - jackson-databind - - - org.apache.nifi - nifi-schema-registry-service-api - test - - - org.apache.nifi - nifi-mock - test - - - org.apache.nifi - nifi-mock-record-utils - test - - - - - org.apache.plc4x - plc4j-driver-modbus - 0.13.0-SNAPSHOT - test - - - org.apache.plc4x - plc4j-driver-opcua - 0.13.0-SNAPSHOT - test - - - org.apache.plc4x - plc4j-driver-s7 - 0.13.0-SNAPSHOT - test - - - org.apache.plc4x - plc4j-driver-simulated - 0.13.0-SNAPSHOT - test - - - org.apache.plc4x - plc4j-transport-pcap-replay - 0.13.0-SNAPSHOT - test - - - org.apache.plc4x - plc4j-transport-raw-socket - 0.13.0-SNAPSHOT - test - - - org.apache.plc4x - plc4j-transport-serial - 0.13.0-SNAPSHOT - test - - - org.apache.plc4x - plc4j-transport-tcp - 0.13.0-SNAPSHOT - test - - - org.apache.plc4x - plc4j-transport-udp - 0.13.0-SNAPSHOT - test - - - org.mockito - mockito-core - test - - - org.mockito - mockito-inline - 5.2.0 - test - - - - org.apache.nifi - nifi-standard-nar - nar - - - \ No newline at end of file diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/util/Plc4xCommonTest.java b/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/util/Plc4xCommonTest.java deleted file mode 100644 index b7f4f3ab5..000000000 --- a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/test/java/org/apache/plc4x/nifi/util/Plc4xCommonTest.java +++ /dev/null @@ -1,214 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You 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. - */ - -package org.apache.plc4x.nifi.util; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.nio.ByteBuffer; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.function.Consumer; - -import org.apache.avro.Schema; -import org.apache.avro.SchemaBuilder; -import org.apache.avro.file.DataFileReader; -import org.apache.avro.file.DataFileWriter; -import org.apache.avro.file.SeekableByteArrayInput; -import org.apache.avro.generic.GenericData; -import org.apache.avro.generic.GenericDatumReader; -import org.apache.avro.generic.GenericDatumWriter; -import org.apache.avro.generic.GenericRecord; -import org.apache.avro.io.DatumReader; -import org.apache.avro.io.DatumWriter; -import org.apache.avro.util.Utf8; -import org.apache.nifi.util.MockFlowFile; - -public class Plc4xCommonTest { - public static final Map originalMap = new HashMap<>(); - public static final Map addressMap = new HashMap<>(); - public static final Map> typeMap = new HashMap<>(); - - - // TODO: BOOL, WORD; DWORD and LWORD are commented because random generation is not working with this types - // or a because a reverse type mapping between avro and PlcTypes is not implemented - public static final Schema schema = SchemaBuilder.builder() - .record("tests").fields() - .nullableBoolean("BOOL", true) - // .nullableBytes("BYTE", new byte[] {1,2}) - // .nullableString("WORD", "4") - .nullableInt("SINT", -5) - .nullableString("USINT", "6") - .nullableInt("INT", 2000) - .nullableString("UINT", "3000") - .nullableString("DINT", "4000") - .nullableString("UDINT", "5000") - // .nullableString("DWORD", "0") - .nullableLong("LINT", 6000L) - .nullableString("ULINT", "7000") - // .nullableString("LWORD", "0") - .nullableFloat("REAL", 1.23456F) - .nullableDouble("LREAL", 2.34567) - .nullableString("CHAR", "c") - .nullableString("WCHAR", "d") - .nullableString("STRING", "this is a string") - .endRecord(); - - static { - // originalMap values are in the type needed to check type mapping between PlcType and Avro - originalMap.put("BOOL", true); - originalMap.put("BYTE", "\u0001"); - originalMap.put("WORD", "4"); - originalMap.put("SINT", -5); - originalMap.put("USINT", "6"); - originalMap.put("INT", 2000); - originalMap.put("UINT", "3000"); - originalMap.put("DINT", "4000"); - originalMap.put("UDINT", "5000"); - originalMap.put("DWORD", Long.valueOf("0")); - originalMap.put("LINT", 6000L); - originalMap.put("ULINT", "7000"); - originalMap.put("LWORD", Long.valueOf("0")); - originalMap.put("REAL", 1.23456F); - originalMap.put("LREAL", 2.34567); - originalMap.put("CHAR", "c"); - originalMap.put("WCHAR", "d"); - originalMap.put("STRING", "this is a string"); - - addressMap.put("BOOL", "RANDOM/v1:BOOL"); - addressMap.put("BYTE", "RANDOM/v2:BYTE"); - addressMap.put("WORD", "RANDOM/v3:WORD"); - addressMap.put("SINT", "RANDOM/v4:SINT"); - addressMap.put("USINT", "RANDOM/v5:USINT"); - addressMap.put("INT", "RANDOM/v6:INT"); - addressMap.put("UINT", "RANDOM/v7:UINT"); - addressMap.put("DINT", "RANDOM/v8:DINT"); - addressMap.put("UDINT", "RANDOM/v9:UDINT"); - addressMap.put("DWORD", "RANDOM/v10:DWORD"); - addressMap.put("LINT", "RANDOM/v11:LINT"); - addressMap.put("ULINT", "RANDOM/v12:ULINT"); - addressMap.put("LWORD", "RANDOM/v13:LWORD"); - addressMap.put("REAL", "RANDOM/v14:REAL"); - addressMap.put("LREAL", "RANDOM/v15:LREAL"); - addressMap.put("CHAR", "RANDOM/v16:CHAR"); - addressMap.put("WCHAR", "RANDOM/v17:WCHAR"); - addressMap.put("STRING", "RANDOM/v18:STRING"); - - typeMap.put("BOOL", Boolean.class); - typeMap.put("BYTE", ByteBuffer.class); - typeMap.put("WORD", Utf8.class); - typeMap.put("SINT", Integer.class); - typeMap.put("USINT", Utf8.class); - typeMap.put("INT", Integer.class); - typeMap.put("UINT", Utf8.class); - typeMap.put("DINT", Utf8.class); - typeMap.put("UDINT", Utf8.class); - typeMap.put("DWORD", Utf8.class); - typeMap.put("LINT", Long.class); - typeMap.put("ULINT", Utf8.class); - typeMap.put("LWORD", Utf8.class); - typeMap.put("REAL", Float.class); - typeMap.put("LREAL", Double.class); - typeMap.put("CHAR", Utf8.class); - typeMap.put("WCHAR", Utf8.class); - typeMap.put("STRING", Utf8.class); - } - - public static Map getAddressMap(){ - Map result = new HashMap<>(); - - addressMap.forEach((k,v) -> { - if (v.startsWith("RANDOM/")) { - if (!v.endsWith("BYTE") && - !v.endsWith("CHAR") && - !v.endsWith("WORD") && - !v.endsWith("STRING")) - result.put(k, v); - } else { - result.put(k, v); - } - - }); - return result; - } - - public static void assertAvroContent(List flowfiles, boolean checkValue, boolean checkType) { - flowfiles.forEach(t -> { - DatumReader dr = new GenericDatumReader<>(); - try (DataFileReader dfr = new DataFileReader<>(new SeekableByteArrayInput(t.toByteArray()), dr)) { - GenericRecord data = null; - while (dfr.hasNext()) { - data = dfr.next(data); - - for (String tag : Plc4xCommonTest.addressMap.keySet()) { - if (data.hasField(tag)) { - // Check value after string conversion - if (checkValue) - assert data.get(tag).toString().equalsIgnoreCase(Plc4xCommonTest.originalMap.get(tag).toString()); - - // Check type - if (checkType) - assert data.get(tag).getClass().equals(Plc4xCommonTest.typeMap.get(tag)); - } - } - } - } catch (IOException e) { - e.printStackTrace(); - } - }); - } - - public static GenericRecord getTestRecord() { - GenericRecord record = new GenericData.Record(schema); - record.put("BOOL", true); - // record.put("BYTE", "\u0001"); - // record.put("WORD", "4"); - record.put("SINT", -5); - record.put("USINT", "6"); - record.put("INT", 2000); - record.put("UINT", "3000"); - record.put("DINT", "4000"); - record.put("UDINT", "5000"); - // record.put("DWORD", "0"); - record.put("LINT", 6000L); - record.put("ULINT", "7000"); - // record.put("LWORD", "0"); - record.put("REAL", 1.23456F); - record.put("LREAL", 2.34567); - record.put("CHAR", "c"); - record.put("WCHAR", "d"); - record.put("STRING", "this is a string"); - return record; - } - - public static byte[] encodeRecord(GenericRecord record){ - ByteArrayOutputStream out = new ByteArrayOutputStream(); - DatumWriter writer = new GenericDatumWriter<>(schema); - DataFileWriter fileWriter = new DataFileWriter<>(writer); - - try { - fileWriter.create(schema, out); - fileWriter.append(record); - fileWriter.close(); - } catch (Exception e) { - e.printStackTrace(); - } - - return out.toByteArray(); - } -} diff --git a/plc4j/integrations/apache-nifi/plc4j-apache-nifi.iml b/plc4j/integrations/apache-nifi/plc4j-apache-nifi.iml deleted file mode 100644 index 30466db07..000000000 --- a/plc4j/integrations/apache-nifi/plc4j-apache-nifi.iml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/plc4j/integrations/apache-nifi/pom.xml b/plc4j/integrations/apache-nifi/pom.xml index 84a8808d9..cac8cc85c 100644 --- a/plc4j/integrations/apache-nifi/pom.xml +++ b/plc4j/integrations/apache-nifi/pom.xml @@ -17,144 +17,25 @@ specific language governing permissions and limitations under the License. --> - - 4.0.0 + - - org.apache.plc4x - plc4j-integrations - 0.13.0-SNAPSHOT - + + org.apache.plc4x.extras + plc4j-integrations + 1.0.0-SNAPSHOT + - plc4j-apache-nifi - pom + plc4j-apache-nifi + pom - PLC4J: Integrations: Apache Nifi - Integration module for integrating PLC4X into Apache Nifi. + PLC4J: Integrations: Apache Nifi + Integration module for integrating PLC4X into Apache Nifi. - - 2024-02-16T14:53:02Z - 1.27.0 - 1.11.3 - - - nifi-plc4x-processors - nifi-plc4x-nar - + + 2026-09-20T15:22:10Z + true + true + - - - - - org.apache.nifi - nifi-nar-maven-plugin - 1.5.1 - true - - - - org.codehaus.mojo - buildnumber-maven-plugin - true - - - validate - - create - - - - - false - false - 7 - true - - buildRevision - buildBranch - - - - - - - - - org.junit - junit-bom - 5.10.3 - pom - import - - - org.apache.avro - avro - ${avro.version} - - - org.apache.nifi - nifi - ${nifi.version} - pom - import - - - org.apache.nifi - nifi-utils - ${nifi.version} - - - org.apache.nifi - nifi-avro-record-utils - ${nifi.version} - - - org.apache.nifi - nifi-json-utils - ${nifi.version} - - - org.apache.nifi - nifi-record-serialization-services - ${nifi.version} - - - org.apache.nifi - nifi-standard-nar - ${nifi.version} - nar - - - org.apache.nifi - nifi-nar-bundles - ${nifi.version} - pom - import - - - io.swagger - swagger-annotations - 1.6.14 - - - org.glassfish.jaxb - jaxb-runtime - 4.0.5 - - - jakarta.xml.bind - jakarta.xml.bind-api - 4.0.2 - - - jakarta.activation - jakarta.activation-api - - - - - - - \ No newline at end of file + diff --git a/plc4j/integrations/plc4j-integrations.iml b/plc4j/integrations/plc4j-integrations.iml deleted file mode 100644 index 30466db07..000000000 --- a/plc4j/integrations/plc4j-integrations.iml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/plc4j/integrations/pom.xml b/plc4j/integrations/pom.xml index 4122b3698..18a5311b3 100644 --- a/plc4j/integrations/pom.xml +++ b/plc4j/integrations/pom.xml @@ -17,14 +17,12 @@ specific language governing permissions and limitations under the License. --> - - - 4.0.0 + - org.apache.plc4x + org.apache.plc4x.extras plc4j-extras - 0.13.0-SNAPSHOT + 1.0.0-SNAPSHOT plc4j-integrations @@ -34,13 +32,7 @@ Parent of all product specific integration modules. - 2024-02-16T14:53:02Z + 2026-09-20T15:22:10Z - - apache-calcite - apache-nifi - apache-kafka - - diff --git a/plc4j/pom.xml b/plc4j/pom.xml index ffb0a4af6..8ff64281e 100644 --- a/plc4j/pom.xml +++ b/plc4j/pom.xml @@ -17,14 +17,12 @@ specific language governing permissions and limitations under the License. --> - - - 4.0.0 + - org.apache.plc4x + org.apache.plc4x.extras plc4x-extras-parent - 0.13.0-SNAPSHOT + 1.0.0-SNAPSHOT plc4j-extras @@ -34,15 +32,9 @@ Examples, integration modules and tools for Java. - 2024-02-16T14:53:02Z + 2026-09-20T15:22:10Z - - examples - integrations - tools - - - pre-integration-test - pre-integration-test + before:integration-test + before:integration-test prepare-agent-integration @@ -134,7 +127,7 @@
org.apache.maven.plugins @@ -149,7 +142,7 @@ false - 11 + 21 @@ -161,7 +154,7 @@ false - 11 + 21
diff --git a/plc4j/tools/malbec/application/nb-configuration.xml b/plc4j/tools/malbec/application/nb-configuration.xml index b248b9dd5..947ae8aeb 100644 --- a/plc4j/tools/malbec/application/nb-configuration.xml +++ b/plc4j/tools/malbec/application/nb-configuration.xml @@ -14,6 +14,5 @@ That way multiple projects can share the same settings (useful for formatting ru Any value defined here will override the pom.xml file value but is only applicable to the current project. --> [{"repository":"php","rule":"S3335"},{"repository":"php","rule":"S3336"},{"repository":"php","rule":"S2002"},{"repository":"php","rule":"S2005"},{"repository":"php","rule":"S3333"},{"repository":"php","rule":"S3334"},{"repository":"Web","rule":"ImgWithoutWidthOrHeightCheck"},{"repository":"php","rule":"S1151"},{"repository":"php","rule":"S3332"},{"repository":"php","rule":"S2001"},{"repository":"Web","rule":"UnclosedTagCheck"},{"repository":"php","rule":"S2000"},{"repository":"java","rule":"S2309"},{"repository":"php","rule":"S126"},{"repository":"java","rule":"S4605"},{"repository":"java","rule":"S4604"},{"repository":"java","rule":"S2308"},{"repository":"xml","rule":"S3282"},{"repository":"php","rule":"S2918"},{"repository":"java","rule":"S2301"},{"repository":"java","rule":"S1696"},{"repository":"java","rule":"S1213"},{"repository":"java","rule":"S1698"},{"repository":"java","rule":"S1699"},{"repository":"php","rule":"S1820"},{"repository":"java","rule":"S3750"},{"repository":"java","rule":"S4288"},{"repository":"java","rule":"S1451"},{"repository":"php","rule":"S122"},{"repository":"java","rule":"S1694"},{"repository":"java","rule":"S1695"},{"repository":"php","rule":"S1821"},{"repository":"php","rule":"S5867"},{"repository":"java","rule":"S6211"},{"repository":"java","rule":"S2096"},{"repository":"java","rule":"S6212"},{"repository":"php","rule":"S2011"},{"repository":"Web","rule":"UnifiedExpressionCheck"},{"repository":"Web","rule":"PageWithoutFaviconCheck"},{"repository":"php","rule":"S134"},{"repository":"java","rule":"S1448"},{"repository":"java","rule":"S2658"},{"repository":"java","rule":"S1449"},{"repository":"java","rule":"S3749"},{"repository":"php","rule":"S139"},{"repository":"java","rule":"S5128"},{"repository":"php","rule":"S2007"},{"repository":"php","rule":"S3337"},{"repository":"php","rule":"S3338"},{"repository":"java","rule":"S1200"},{"repository":"Web","rule":"ComplexityCheck"},{"repository":"Web","rule":"RequiredAttributeCheck"},{"repository":"php","rule":"S104"},{"repository":"java","rule":"S3658"},{"repository":"java","rule":"S2208"},{"repository":"java","rule":"S2444"},{"repository":"java","rule":"S2203"},{"repository":"java","rule":"S2325"},{"repository":"java","rule":"S3414"},{"repository":"Web","rule":"S1829"},{"repository":"java","rule":"S4174"},{"repository":"Web","rule":"IllegalTabCheck"},{"repository":"java","rule":"S1106"},{"repository":"java","rule":"S1228"},{"repository":"java","rule":"S1107"},{"repository":"java","rule":"S1108"},{"repository":"java","rule":"S1109"},{"repository":"xml","rule":"S3373"},{"repository":"java","rule":"S1105"},{"repository":"php","rule":"S5856"},{"repository":"java","rule":"S2063"},{"repository":"java","rule":"S3030"},{"repository":"java","rule":"S3032"},{"repository":"Web","rule":"DoubleQuotesCheck"},{"repository":"Web","rule":"MaxLineLengthCheck"},{"repository":"java","rule":"S105"},{"repository":"Web","rule":"LongJavaScriptCheck"},{"repository":"java","rule":"S103"},{"repository":"Web","rule":"WmodeIsWindowCheck"},{"repository":"java","rule":"S104"},{"repository":"java","rule":"S109"},{"repository":"java","rule":"S113"},{"repository":"java","rule":"S4926"},{"repository":"java","rule":"S4248"},{"repository":"java","rule":"S1774"},{"repository":"php","rule":"S1105"},{"repository":"php","rule":"S1106"},{"repository":"php","rule":"S1121"},{"repository":"Web","rule":"InputWithoutLabelCheck"},{"repository":"xml","rule":"S2260"},{"repository":"java","rule":"S2059"},{"repository":"java","rule":"S1641"},{"repository":"java","rule":"S2972"},{"repository":"java","rule":"S2973"},{"repository":"java","rule":"S2974"},{"repository":"java","rule":"S2057"},{"repository":"java","rule":"S6411"},{"repository":"php","rule":"S1117"},{"repository":"java","rule":"S3052"},{"repository":"php","rule":"S1451"},{"repository":"Web","rule":"NonConsecutiveHeadingCheck"},{"repository":"java","rule":"S126"},{"repository":"java","rule":"S923"},{"repository":"java","rule":"S134"},{"repository":"java","rule":"S1315"},{"repository":"java","rule":"S1312"},{"repository":"java","rule":"S1314"},{"repository":"java","rule":"S4266"},{"repository":"java","rule":"S1310"},{"repository":"java","rule":"S2196"},{"repository":"java","rule":"S2197"},{"repository":"java","rule":"S118"},{"repository":"java","rule":"S1309"},{"repository":"java","rule":"S3725"},{"repository":"java","rule":"S121"},{"repository":"java","rule":"S122"},{"repository":"Web","rule":"DynamicJspIncludeCheck"},{"repository":"php","rule":"S1578"},{"repository":"php","rule":"S5935"},{"repository":"Web","rule":"InlineStyleCheck"},{"repository":"java","rule":"S3047"},{"repository":"java","rule":"S1541"},{"repository":"java","rule":"S2260"},{"repository":"java","rule":"S2141"},{"repository":"php","rule":"S1311"},{"repository":"java","rule":"S2384"},{"repository":"php","rule":"S4142"},{"repository":"xml","rule":"S105"},{"repository":"java","rule":"S2701"},{"repository":"xml","rule":"S103"},{"repository":"java","rule":"S2148"},{"repository":"php","rule":"S881"},{"repository":"java","rule":"S2143"},{"repository":"java","rule":"S1176"},{"repository":"java","rule":"S1160"},{"repository":"php","rule":"S1200"},{"repository":"java","rule":"S2250"},{"repository":"java","rule":"S818"},{"repository":"java","rule":"S1162"},{"repository":"java","rule":"S4551"},{"repository":"java","rule":"S2131"},{"repository":"java","rule":"S138"},{"repository":"java","rule":"S139"},{"repository":"Web","rule":"IllegalNamespaceCheck"},{"repository":"php","rule":"S5915"},{"repository":"php","rule":"S1314"},{"repository":"java","rule":"S1166"},{"repository":"php","rule":"S1799"},{"repository":"java","rule":"S5793"},{"repository":"java","rule":"S1194"},{"repository":"java","rule":"S2162"},{"repository":"java","rule":"S2164"},{"repository":"xml","rule":"S3419"},{"repository":"Web","rule":"WhiteSpaceAroundCheck"},{"repository":"java","rule":"S3937"},{"repository":"xml","rule":"S1120"},{"repository":"java","rule":"S1996"},{"repository":"Web","rule":"MultiplePageDirectivesCheck"},{"repository":"xml","rule":"S3420"},{"repository":"java","rule":"S3254"},{"repository":"xml","rule":"S3423"},{"repository":"java","rule":"S2047"},{"repository":"php","rule":"S1541"},{"repository":"Web","rule":"InternationalizationCheck"},{"repository":"java","rule":"S3242"},{"repository":"java","rule":"S6073"},{"repository":"php","rule":"S2070"},{"repository":"java","rule":"S2959"},{"repository":"Web","rule":"LinkToNothingCheck"},{"repository":"Web","rule":"S1436"},{"repository":"java","rule":"S2039"},{"repository":"xml","rule":"S2321"},{"repository":"java","rule":"S1188"},{"repository":"java","rule":"S1067"},{"repository":"java","rule":"S2156"},{"repository":"java","rule":"S3366"},{"repository":"Web","rule":"LinksIdenticalTextsDifferentTargetsCheck"},{"repository":"php","rule":"S2047"},{"repository":"php","rule":"S2046"},{"repository":"java","rule":"S5970"},{"repository":"php","rule":"S2043"},{"repository":"php","rule":"S2042"},{"repository":"php","rule":"S1990"},{"repository":"php","rule":"S2044"},{"repository":"java","rule":"S864"},{"repository":"Web","rule":"MouseEventWithoutKeyboardEquivalentCheck"},{"repository":"java","rule":"S5979"},{"repository":"java","rule":"NoSonar"},{"repository":"java","rule":"S5977"},{"repository":"java","rule":"S5612"},{"repository":"java","rule":"S1258"},{"repository":"java","rule":"S3437"},{"repository":"Web","rule":"FileLengthCheck"},{"repository":"Web","rule":"JspScriptletCheck"},{"repository":"java","rule":"S2221"},{"repository":"java","rule":"S1132"},{"repository":"java","rule":"S3553"},{"repository":"php","rule":"NoSonar"},{"repository":"Web","rule":"IllegalTagLibsCheck"},{"repository":"php","rule":"S2050"},{"repository":"java","rule":"S3306"},{"repository":"java","rule":"S2698"},{"repository":"php","rule":"S1996"},{"repository":"php","rule":"S2964"},{"repository":"java","rule":"S2693"},{"repository":"java","rule":"S1120"},{"repository":"java","rule":"S2694"},{"repository":"java","rule":"S2211"},{"repository":"php","rule":"S1997"},{"repository":"java","rule":"S2333"},{"repository":"java","rule":"S1244"},{"repository":"php","rule":"S5899"},{"repository":"java","rule":"S1151"},{"repository":"Web","rule":"IllegalElementCheck"},{"repository":"java","rule":"S5194"},{"repository":"php","rule":"S2260"},{"repository":"java","rule":"S888"},{"repository":"java","rule":"S1711"},{"repository":"java","rule":"S3578"},{"repository":"php","rule":"S2278"},{"repository":"php","rule":"S2036"},{"repository":"php","rule":"S2277"},{"repository":"php","rule":"S1067"},{"repository":"php","rule":"S2830"},{"repository":"php","rule":"S2038"},{"repository":"php","rule":"S2037"},{"repository":"php","rule":"S5783"},{"repository":"java","rule":"S1939"},{"repository":"java","rule":"S1821"},{"repository":"java","rule":"S1942"},{"repository":"java","rule":"S1943"},{"repository":"java","rule":"S881"},{"repository":"java","rule":"S5867"},{"repository":"java","rule":"S1147"},{"repository":"java","rule":"S1820"},{"repository":"java","rule":"S1941"},{"repository":"java","rule":"S1142"},{"repository":"php","rule":"S2701"},{"repository":"Web","rule":"HeaderCheck"}] - JDK_21__System_
diff --git a/plc4j/tools/malbec/application/pom.xml b/plc4j/tools/malbec/application/pom.xml index d8158efab..f22bfd7b1 100644 --- a/plc4j/tools/malbec/application/pom.xml +++ b/plc4j/tools/malbec/application/pom.xml @@ -23,7 +23,7 @@ org.apache.plc4x malbec-parent - 0.13.0-SNAPSHOT + 1.1.0-SNAPSHOT org.apache.plc4x.malbec @@ -136,6 +136,16 @@ org-netbeans-libs-osgi ${netbeans.version} + + org.bouncycastle + bcprov-jdk18on + 1.85 + + + org.bouncycastle + bcutil-jdk18on + 1.85 + ${project.groupId} branding @@ -144,32 +154,97 @@ org.apache.plc4x.malbec.plc4x s7 - 0.13.0-SNAPSHOT + ${project.version} org.apache.plc4x.malbec.plc4x services - 0.13.0-SNAPSHOT + ${project.version} org.apache.plc4x.malbec.plc4x modbus - 0.13.0-SNAPSHOT + ${project.version} org.apache.plc4x.malbec.plc4x eip - 0.13.0-SNAPSHOT + ${project.version} org.apache.plc4x.malbec.projecttype hmi - 0.13.0-SNAPSHOT + ${project.version} org.apache.plc4x.malbec.projecttype type - 0.13.0-SNAPSHOT + ${project.version} + + + org.apache.plc4x.malbec.projecttype + comms + ${project.version} + + + org.apache.plc4x.malbec.projecttype + recipes + ${project.version} + + + org.apache.plc4x.malbec.projecttype + scripts + ${project.version} + + + org.apache.plc4x.malbec.projecttype + information + ${project.version} + + + org.apache.plc4x.malbec.projecttype + tgl + ${project.version} + + + org.apache.plc4x.malbec.projecttype + securities + ${project.version} + + + org.apache.plc4x.malbec.projecttype + udt + ${project.version} + + + org.apache.plc4x.malbec.projecttype + events + ${project.version} + + + org.apache.plc4x.malbec.projecttype + pics + ${project.version} + + + org.apache.plc4x.malbec.projecttype + htc + ${project.version} + + + org.apache.plc4x.malbec.s88 + project + ${project.version} + + + org.apache.plc4x.malbec.s88 + data + ${project.version} + + + org.apache.plc4x.malbec.s88 + batch + ${project.version} diff --git a/plc4j/tools/malbec/application/src/test/java/org/apache/plc4x/malbec/ApplicationTest.java b/plc4j/tools/malbec/application/src/test/java/org/apache/plc4x/malbec/ApplicationTest.java index 310d74dc9..caf697c08 100644 --- a/plc4j/tools/malbec/application/src/test/java/org/apache/plc4x/malbec/ApplicationTest.java +++ b/plc4j/tools/malbec/application/src/test/java/org/apache/plc4x/malbec/ApplicationTest.java @@ -1,3 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + package org.apache.plc4x.malbec; import java.util.logging.Level; diff --git a/plc4j/tools/malbec/branding/pom.xml b/plc4j/tools/malbec/branding/pom.xml index 1ef54b026..dbe5e485b 100644 --- a/plc4j/tools/malbec/branding/pom.xml +++ b/plc4j/tools/malbec/branding/pom.xml @@ -23,13 +23,12 @@ org.apache.plc4x malbec-parent - 0.13.0-SNAPSHOT + 1.1.0-SNAPSHOT org.apache.plc4x.malbec branding - 0.13.0-SNAPSHOT - ${project.groupId}:${project.artifactId} + ${project.groupId}:${project.artifactId} nbm diff --git a/plc4j/tools/malbec/branding/src/main/nbm-branding/core/core.jar/org/netbeans/core/startup/Bundle.properties b/plc4j/tools/malbec/branding/src/main/nbm-branding/core/core.jar/org/netbeans/core/startup/Bundle.properties index c10d44672..19f8439cc 100644 --- a/plc4j/tools/malbec/branding/src/main/nbm-branding/core/core.jar/org/netbeans/core/startup/Bundle.properties +++ b/plc4j/tools/malbec/branding/src/main/nbm-branding/core/core.jar/org/netbeans/core/startup/Bundle.properties @@ -16,8 +16,8 @@ # specific language governing permissions and limitations # under the License. # -currentVersion=app {0} -LBL_splash_window_title=Starting app +currentVersion=Malbec {0} +LBL_splash_window_title=Starting Malbec SPLASH_HEIGHT=325 SPLASH_WIDTH=490 SplashProgressBarBounds=12,258,472,11 diff --git a/plc4j/tools/malbec/branding/src/main/nbm-branding/modules/org-netbeans-core-windows.jar/org/netbeans/core/windows/view/ui/Bundle.properties b/plc4j/tools/malbec/branding/src/main/nbm-branding/modules/org-netbeans-core-windows.jar/org/netbeans/core/windows/view/ui/Bundle.properties index 28f35eeb7..8e08db3a4 100644 --- a/plc4j/tools/malbec/branding/src/main/nbm-branding/modules/org-netbeans-core-windows.jar/org/netbeans/core/windows/view/ui/Bundle.properties +++ b/plc4j/tools/malbec/branding/src/main/nbm-branding/modules/org-netbeans-core-windows.jar/org/netbeans/core/windows/view/ui/Bundle.properties @@ -16,5 +16,5 @@ # specific language governing permissions and limitations # under the License. # -CTL_MainWindow_Title=app {0} -CTL_MainWindow_Title_No_Project=app {0} +CTL_MainWindow_Title=Malbec +CTL_MainWindow_Title_No_Project=Malbec diff --git a/plc4j/tools/malbec/branding/src/main/nbm-branding/modules/org-netbeans-core.jar/org/netbeans/core/ui/Bundle.properties b/plc4j/tools/malbec/branding/src/main/nbm-branding/modules/org-netbeans-core.jar/org/netbeans/core/ui/Bundle.properties index 57c2e49dd..ad49f0c0a 100644 --- a/plc4j/tools/malbec/branding/src/main/nbm-branding/modules/org-netbeans-core.jar/org/netbeans/core/ui/Bundle.properties +++ b/plc4j/tools/malbec/branding/src/main/nbm-branding/modules/org-netbeans-core.jar/org/netbeans/core/ui/Bundle.properties @@ -16,4 +16,4 @@ # specific language governing permissions and limitations # under the License. # -LBL_ProductInformation=app +LBL_ProductInformation=Malbec diff --git a/plc4j/tools/malbec/malbec-core/css/pom.xml b/plc4j/tools/malbec/malbec-core/css/pom.xml index 4b0f93271..c38dc4a4c 100644 --- a/plc4j/tools/malbec/malbec-core/css/pom.xml +++ b/plc4j/tools/malbec/malbec-core/css/pom.xml @@ -1,13 +1,30 @@ + 4.0.0 org.apache.plc4x.malbec.core core - 0.13.0-SNAPSHOT + 1.1.0-SNAPSHOT - - org.apache.plc4x.malbec.core + css ${project.groupId}:${project.artifactId} nbm diff --git a/plc4j/tools/malbec/malbec-core/css/src/main/java/org/apache/plc4x/malbec/core/css/impl/BorrarOptionsPanelController.java b/plc4j/tools/malbec/malbec-core/css/src/main/java/org/apache/plc4x/malbec/core/css/impl/BorrarOptionsPanelController.java index 314972393..1c8803601 100644 --- a/plc4j/tools/malbec/malbec-core/css/src/main/java/org/apache/plc4x/malbec/core/css/impl/BorrarOptionsPanelController.java +++ b/plc4j/tools/malbec/malbec-core/css/src/main/java/org/apache/plc4x/malbec/core/css/impl/BorrarOptionsPanelController.java @@ -1,7 +1,22 @@ /* - * Click nbfs://nbhost/SystemFileSystem/Templates/Licenses/license-default.txt to change this license - * Click nbfs://nbhost/SystemFileSystem/Templates/NetBeansModuleDevelopment-files/template_mypluginOptionsPanelController.java to edit this template + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. */ + package org.apache.plc4x.malbec.core.css.impl; import java.beans.PropertyChangeListener; diff --git a/plc4j/tools/malbec/malbec-core/css/src/main/java/org/apache/plc4x/malbec/core/css/impl/BorrarPanel.java b/plc4j/tools/malbec/malbec-core/css/src/main/java/org/apache/plc4x/malbec/core/css/impl/BorrarPanel.java index 713bb04ef..70b60c7dd 100644 --- a/plc4j/tools/malbec/malbec-core/css/src/main/java/org/apache/plc4x/malbec/core/css/impl/BorrarPanel.java +++ b/plc4j/tools/malbec/malbec-core/css/src/main/java/org/apache/plc4x/malbec/core/css/impl/BorrarPanel.java @@ -1,7 +1,22 @@ /* - * Click nbfs://nbhost/SystemFileSystem/Templates/Licenses/license-default.txt to change this license - * Click nbfs://nbhost/SystemFileSystem/Templates/NetBeansModuleDevelopment-files/template_mypluginPanel.java to edit this template + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. */ + package org.apache.plc4x.malbec.core.css.impl; final class BorrarPanel extends javax.swing.JPanel { diff --git a/plc4j/tools/malbec/malbec-core/css/src/main/java/org/apache/plc4x/malbec/core/css/impl/PhoebusOptionsPanelController.java b/plc4j/tools/malbec/malbec-core/css/src/main/java/org/apache/plc4x/malbec/core/css/impl/PhoebusOptionsPanelController.java index c26672304..87732727f 100644 --- a/plc4j/tools/malbec/malbec-core/css/src/main/java/org/apache/plc4x/malbec/core/css/impl/PhoebusOptionsPanelController.java +++ b/plc4j/tools/malbec/malbec-core/css/src/main/java/org/apache/plc4x/malbec/core/css/impl/PhoebusOptionsPanelController.java @@ -1,7 +1,22 @@ /* - * Click nbfs://nbhost/SystemFileSystem/Templates/Licenses/license-default.txt to change this license - * Click nbfs://nbhost/SystemFileSystem/Templates/NetBeansModuleDevelopment-files/template_mypluginOptionsPanelController.java to edit this template + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. */ + package org.apache.plc4x.malbec.core.css.impl; import java.beans.PropertyChangeListener; diff --git a/plc4j/tools/malbec/malbec-core/css/src/main/java/org/apache/plc4x/malbec/core/css/impl/PhoebusPanel.java b/plc4j/tools/malbec/malbec-core/css/src/main/java/org/apache/plc4x/malbec/core/css/impl/PhoebusPanel.java index 5f98b64c4..5db49d775 100644 --- a/plc4j/tools/malbec/malbec-core/css/src/main/java/org/apache/plc4x/malbec/core/css/impl/PhoebusPanel.java +++ b/plc4j/tools/malbec/malbec-core/css/src/main/java/org/apache/plc4x/malbec/core/css/impl/PhoebusPanel.java @@ -1,7 +1,22 @@ /* - * Click nbfs://nbhost/SystemFileSystem/Templates/Licenses/license-default.txt to change this license - * Click nbfs://nbhost/SystemFileSystem/Templates/NetBeansModuleDevelopment-files/template_mypluginPanel.java to edit this template + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. */ + package org.apache.plc4x.malbec.core.css.impl; final class PhoebusPanel extends javax.swing.JPanel { diff --git a/plc4j/tools/malbec/malbec-core/css/src/main/resources/org/apache/plc4x/malbec/core/css/impl/Bundle.properties b/plc4j/tools/malbec/malbec-core/css/src/main/resources/org/apache/plc4x/malbec/core/css/impl/Bundle.properties index 988520a4e..ab6ef8c62 100644 --- a/plc4j/tools/malbec/malbec-core/css/src/main/resources/org/apache/plc4x/malbec/core/css/impl/Bundle.properties +++ b/plc4j/tools/malbec/malbec-core/css/src/main/resources/org/apache/plc4x/malbec/core/css/impl/Bundle.properties @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + #Localized module labels. Defaults taken from POM (, , ) if unset. #OpenIDE-Module-Name= #OpenIDE-Module-Short-Description= diff --git a/plc4j/tools/malbec/malbec-core/ctx/nb-configuration.xml b/plc4j/tools/malbec/malbec-core/ctx/nb-configuration.xml index 3c1561294..5d46208e3 100644 --- a/plc4j/tools/malbec/malbec-core/ctx/nb-configuration.xml +++ b/plc4j/tools/malbec/malbec-core/ctx/nb-configuration.xml @@ -14,6 +14,5 @@ That way multiple projects can share the same settings (useful for formatting ru Any value defined here will override the pom.xml file value but is only applicable to the current project. --> [{"repository":"php","rule":"S3335"},{"repository":"php","rule":"S3336"},{"repository":"php","rule":"S2002"},{"repository":"php","rule":"S2005"},{"repository":"php","rule":"S3333"},{"repository":"php","rule":"S3334"},{"repository":"Web","rule":"ImgWithoutWidthOrHeightCheck"},{"repository":"php","rule":"S1151"},{"repository":"php","rule":"S3332"},{"repository":"php","rule":"S2001"},{"repository":"php","rule":"S2000"},{"repository":"typescript","rule":"S4798"},{"repository":"javascript","rule":"S3801"},{"repository":"typescript","rule":"S2138"},{"repository":"typescript","rule":"S2376"},{"repository":"java","rule":"S4288"},{"repository":"php","rule":"S5867"},{"repository":"java","rule":"S6211"},{"repository":"java","rule":"S2096"},{"repository":"java","rule":"S6212"},{"repository":"php","rule":"S2011"},{"repository":"typescript","rule":"S4328"},{"repository":"typescript","rule":"S4204"},{"repository":"typescript","rule":"S4324"},{"repository":"Web","rule":"PageWithoutFaviconCheck"},{"repository":"typescript","rule":"S4327"},{"repository":"typescript","rule":"S4326"},{"repository":"typescript","rule":"S3353"},{"repository":"typescript","rule":"S1172"},{"repository":"typescript","rule":"S2260"},{"repository":"java","rule":"S5128"},{"repository":"php","rule":"S2007"},{"repository":"php","rule":"S3337"},{"repository":"php","rule":"S3338"},{"repository":"javascript","rule":"S139"},{"repository":"javascript","rule":"S135"},{"repository":"javascript","rule":"S138"},{"repository":"javascript","rule":"S131"},{"repository":"javascript","rule":"S134"},{"repository":"javascript","rule":"S2817"},{"repository":"typescript","rule":"S3003"},{"repository":"typescript","rule":"S1067"},{"repository":"typescript","rule":"S1066"},{"repository":"Web","rule":"S1829"},{"repository":"java","rule":"S4174"},{"repository":"Web","rule":"IllegalTabCheck"},{"repository":"typescript","rule":"S3499"},{"repository":"typescript","rule":"S3257"},{"repository":"typescript","rule":"S1192"},{"repository":"typescript","rule":"S3498"},{"repository":"php","rule":"S5856"},{"repository":"java","rule":"S2063"},{"repository":"java","rule":"S3030"},{"repository":"java","rule":"S3032"},{"repository":"Web","rule":"DoubleQuotesCheck"},{"repository":"Web","rule":"MaxLineLengthCheck"},{"repository":"java","rule":"S105"},{"repository":"typescript","rule":"S1488"},{"repository":"Web","rule":"LongJavaScriptCheck"},{"repository":"java","rule":"S103"},{"repository":"Web","rule":"WmodeIsWindowCheck"},{"repository":"java","rule":"S104"},{"repository":"java","rule":"S109"},{"repository":"typescript","rule":"S3786"},{"repository":"javascript","rule":"S1821"},{"repository":"java","rule":"S113"},{"repository":"java","rule":"S4248"},{"repository":"php","rule":"S1105"},{"repository":"php","rule":"S1106"},{"repository":"php","rule":"S1121"},{"repository":"typescript","rule":"S3317"},{"repository":"typescript","rule":"S1131"},{"repository":"java","rule":"S2059"},{"repository":"java","rule":"S2057"},{"repository":"java","rule":"S6411"},{"repository":"php","rule":"S1117"},{"repository":"java","rule":"S3052"},{"repository":"typescript","rule":"S881"},{"repository":"php","rule":"S1451"},{"repository":"Web","rule":"NonConsecutiveHeadingCheck"},{"repository":"java","rule":"S126"},{"repository":"typescript","rule":"S5867"},{"repository":"java","rule":"S134"},{"repository":"java","rule":"S4266"},{"repository":"java","rule":"S2196"},{"repository":"java","rule":"S2197"},{"repository":"java","rule":"S118"},{"repository":"typescript","rule":"S1154"},{"repository":"java","rule":"S121"},{"repository":"java","rule":"S122"},{"repository":"php","rule":"S1578"},{"repository":"php","rule":"S5935"},{"repository":"java","rule":"S3047"},{"repository":"php","rule":"S4142"},{"repository":"typescript","rule":"S1441"},{"repository":"java","rule":"S2701"},{"repository":"typescript","rule":"S1440"},{"repository":"php","rule":"S881"},{"repository":"java","rule":"S138"},{"repository":"typescript","rule":"S3514"},{"repository":"java","rule":"S139"},{"repository":"typescript","rule":"S2427"},{"repository":"typescript","rule":"S3512"},{"repository":"typescript","rule":"S2424"},{"repository":"typescript","rule":"S3513"},{"repository":"typescript","rule":"S1451"},{"repository":"Web","rule":"IllegalNamespaceCheck"},{"repository":"xml","rule":"S3419"},{"repository":"typescript","rule":"S3525"},{"repository":"Web","rule":"WhiteSpaceAroundCheck"},{"repository":"typescript","rule":"S3402"},{"repository":"typescript","rule":"S1105"},{"repository":"typescript","rule":"S3524"},{"repository":"java","rule":"S3937"},{"repository":"xml","rule":"S1120"},{"repository":"java","rule":"S1996"},{"repository":"xml","rule":"S3420"},{"repository":"xml","rule":"S3423"},{"repository":"Web","rule":"InternationalizationCheck"},{"repository":"java","rule":"S6073"},{"repository":"css","rule":"S4664"},{"repository":"typescript","rule":"S2208"},{"repository":"typescript","rule":"S4622"},{"repository":"typescript","rule":"S3533"},{"repository":"php","rule":"S2070"},{"repository":"typescript","rule":"S1117"},{"repository":"typescript","rule":"S1116"},{"repository":"java","rule":"S2959"},{"repository":"typescript","rule":"S1110"},{"repository":"Web","rule":"S1436"},{"repository":"xml","rule":"S2321"},{"repository":"php","rule":"S2047"},{"repository":"php","rule":"S2046"},{"repository":"typescript","rule":"S1528"},{"repository":"php","rule":"S2043"},{"repository":"javascript","rule":"S117"},{"repository":"php","rule":"S2042"},{"repository":"php","rule":"S2044"},{"repository":"javascript","rule":"S113"},{"repository":"Web","rule":"MouseEventWithoutKeyboardEquivalentCheck"},{"repository":"javascript","rule":"S4139"},{"repository":"typescript","rule":"S1526"},{"repository":"typescript","rule":"S1525"},{"repository":"php","rule":"NoSonar"},{"repository":"typescript","rule":"S1539"},{"repository":"Web","rule":"IllegalTagLibsCheck"},{"repository":"php","rule":"S2050"},{"repository":"typescript","rule":"S1774"},{"repository":"javascript","rule":"S126"},{"repository":"typescript","rule":"S1530"},{"repository":"javascript","rule":"S121"},{"repository":"typescript","rule":"S1537"},{"repository":"typescript","rule":"S1535"},{"repository":"javascript","rule":"S122"},{"repository":"typescript","rule":"S909"},{"repository":"php","rule":"S5899"},{"repository":"java","rule":"S5194"},{"repository":"typescript","rule":"S3723"},{"repository":"php","rule":"S2260"},{"repository":"typescript","rule":"S1541"},{"repository":"java","rule":"S1711"},{"repository":"javascript","rule":"S1192"},{"repository":"php","rule":"S2278"},{"repository":"php","rule":"S2036"},{"repository":"php","rule":"S2277"},{"repository":"php","rule":"S1067"},{"repository":"php","rule":"S2038"},{"repository":"php","rule":"S2037"},{"repository":"javascript","rule":"S106"},{"repository":"javascript","rule":"S109"},{"repository":"php","rule":"S5783"},{"repository":"javascript","rule":"S3498"},{"repository":"javascript","rule":"S103"},{"repository":"javascript","rule":"S3499"},{"repository":"javascript","rule":"S105"},{"repository":"javascript","rule":"S104"},{"repository":"typescript","rule":"S1438"},{"repository":"java","rule":"S1939"},{"repository":"typescript","rule":"S3973"},{"repository":"javascript","rule":"S100"},{"repository":"java","rule":"S1821"},{"repository":"java","rule":"S1942"},{"repository":"java","rule":"S1943"},{"repository":"java","rule":"S1820"},{"repository":"java","rule":"S1941"},{"repository":"Web","rule":"HeaderCheck"},{"repository":"Web","rule":"UnclosedTagCheck"},{"repository":"typescript","rule":"S2817"},{"repository":"javascript","rule":"S3003"},{"repository":"typescript","rule":"S2933"},{"repository":"java","rule":"S2309"},{"repository":"php","rule":"S126"},{"repository":"css","rule":"S5362"},{"repository":"java","rule":"S4605"},{"repository":"java","rule":"S4604"},{"repository":"java","rule":"S2308"},{"repository":"xml","rule":"S3282"},{"repository":"php","rule":"S2918"},{"repository":"java","rule":"S2301"},{"repository":"java","rule":"S1696"},{"repository":"java","rule":"S1213"},{"repository":"typescript","rule":"S139"},{"repository":"java","rule":"S1698"},{"repository":"java","rule":"S1699"},{"repository":"php","rule":"S1820"},{"repository":"java","rule":"S3750"},{"repository":"javascript","rule":"S1172"},{"repository":"java","rule":"S1451"},{"repository":"php","rule":"S122"},{"repository":"java","rule":"S1694"},{"repository":"java","rule":"S1695"},{"repository":"php","rule":"S1821"},{"repository":"javascript","rule":"S2260"},{"repository":"javascript","rule":"S3353"},{"repository":"javascript","rule":"S4326"},{"repository":"Web","rule":"UnifiedExpressionCheck"},{"repository":"php","rule":"S134"},{"repository":"java","rule":"S1448"},{"repository":"java","rule":"S2658"},{"repository":"java","rule":"S1449"},{"repository":"java","rule":"S3749"},{"repository":"php","rule":"S139"},{"repository":"javascript","rule":"S1067"},{"repository":"javascript","rule":"S1066"},{"repository":"java","rule":"S1200"},{"repository":"Web","rule":"ComplexityCheck"},{"repository":"javascript","rule":"S2376"},{"repository":"typescript","rule":"S3801"},{"repository":"Web","rule":"RequiredAttributeCheck"},{"repository":"javascript","rule":"S2138"},{"repository":"php","rule":"S104"},{"repository":"java","rule":"S3658"},{"repository":"java","rule":"S2208"},{"repository":"java","rule":"S2444"},{"repository":"javascript","rule":"S1154"},{"repository":"java","rule":"S2203"},{"repository":"java","rule":"S2325"},{"repository":"java","rule":"S3414"},{"repository":"typescript","rule":"S2966"},{"repository":"java","rule":"S1106"},{"repository":"java","rule":"S1228"},{"repository":"java","rule":"S1107"},{"repository":"java","rule":"S1108"},{"repository":"java","rule":"S1109"},{"repository":"xml","rule":"S3373"},{"repository":"java","rule":"S1105"},{"repository":"javascript","rule":"S5867"},{"repository":"java","rule":"S4926"},{"repository":"java","rule":"S1774"},{"repository":"javascript","rule":"S1131"},{"repository":"javascript","rule":"S3798"},{"repository":"javascript","rule":"S3317"},{"repository":"Web","rule":"InputWithoutLabelCheck"},{"repository":"javascript","rule":"S881"},{"repository":"xml","rule":"S2260"},{"repository":"java","rule":"S1641"},{"repository":"java","rule":"S2972"},{"repository":"java","rule":"S2973"},{"repository":"java","rule":"S2974"},{"repository":"javascript","rule":"S3786"},{"repository":"javascript","rule":"S1488"},{"repository":"java","rule":"S923"},{"repository":"typescript","rule":"S1821"},{"repository":"javascript","rule":"S1117"},{"repository":"javascript","rule":"S1116"},{"repository":"java","rule":"S1315"},{"repository":"javascript","rule":"S1110"},{"repository":"java","rule":"S1312"},{"repository":"java","rule":"S1314"},{"repository":"java","rule":"S1310"},{"repository":"javascript","rule":"S3533"},{"repository":"javascript","rule":"S2208"},{"repository":"java","rule":"S1309"},{"repository":"java","rule":"S3725"},{"repository":"Web","rule":"DynamicJspIncludeCheck"},{"repository":"Web","rule":"InlineStyleCheck"},{"repository":"java","rule":"S1541"},{"repository":"java","rule":"S2260"},{"repository":"java","rule":"S2141"},{"repository":"php","rule":"S1311"},{"repository":"java","rule":"S2384"},{"repository":"javascript","rule":"S3760"},{"repository":"javascript","rule":"S3523"},{"repository":"javascript","rule":"S3402"},{"repository":"javascript","rule":"S3524"},{"repository":"javascript","rule":"S3525"},{"repository":"xml","rule":"S105"},{"repository":"typescript","rule":"S4157"},{"repository":"xml","rule":"S103"},{"repository":"java","rule":"S2148"},{"repository":"java","rule":"S2143"},{"repository":"java","rule":"S1176"},{"repository":"java","rule":"S1160"},{"repository":"php","rule":"S1200"},{"repository":"java","rule":"S2250"},{"repository":"java","rule":"S818"},{"repository":"java","rule":"S1162"},{"repository":"java","rule":"S4551"},{"repository":"java","rule":"S2131"},{"repository":"javascript","rule":"S1451"},{"repository":"javascript","rule":"S3512"},{"repository":"javascript","rule":"S2424"},{"repository":"javascript","rule":"S3513"},{"repository":"javascript","rule":"S3514"},{"repository":"javascript","rule":"S3757"},{"repository":"javascript","rule":"S3758"},{"repository":"javascript","rule":"S2427"},{"repository":"javascript","rule":"S2428"},{"repository":"javascript","rule":"S1105"},{"repository":"php","rule":"S5915"},{"repository":"php","rule":"S1314"},{"repository":"java","rule":"S1166"},{"repository":"php","rule":"S1799"},{"repository":"java","rule":"S5793"},{"repository":"java","rule":"S1194"},{"repository":"java","rule":"S2162"},{"repository":"java","rule":"S2164"},{"repository":"javascript","rule":"S1440"},{"repository":"javascript","rule":"S1441"},{"repository":"javascript","rule":"S1442"},{"repository":"Web","rule":"MultiplePageDirectivesCheck"},{"repository":"java","rule":"S3254"},{"repository":"java","rule":"S2047"},{"repository":"php","rule":"S1541"},{"repository":"java","rule":"S3242"},{"repository":"javascript","rule":"S3973"},{"repository":"javascript","rule":"S1438"},{"repository":"Web","rule":"LinkToNothingCheck"},{"repository":"java","rule":"S2039"},{"repository":"java","rule":"S1188"},{"repository":"java","rule":"S1067"},{"repository":"java","rule":"S2156"},{"repository":"java","rule":"S3366"},{"repository":"Web","rule":"LinksIdenticalTextsDifferentTargetsCheck"},{"repository":"java","rule":"S5970"},{"repository":"typescript","rule":"S100"},{"repository":"typescript","rule":"S103"},{"repository":"php","rule":"S1990"},{"repository":"typescript","rule":"S105"},{"repository":"typescript","rule":"S104"},{"repository":"javascript","rule":"S1541"},{"repository":"java","rule":"S864"},{"repository":"javascript","rule":"S3723"},{"repository":"java","rule":"S5979"},{"repository":"java","rule":"NoSonar"},{"repository":"java","rule":"S5977"},{"repository":"java","rule":"S5612"},{"repository":"java","rule":"S1258"},{"repository":"java","rule":"S3437"},{"repository":"Web","rule":"FileLengthCheck"},{"repository":"Web","rule":"JspScriptletCheck"},{"repository":"java","rule":"S2221"},{"repository":"java","rule":"S1132"},{"repository":"javascript","rule":"S909"},{"repository":"java","rule":"S3553"},{"repository":"typescript","rule":"S113"},{"repository":"javascript","rule":"S1530"},{"repository":"javascript","rule":"S1774"},{"repository":"javascript","rule":"S1535"},{"repository":"javascript","rule":"S1537"},{"repository":"javascript","rule":"S1539"},{"repository":"java","rule":"S3306"},{"repository":"typescript","rule":"S106"},{"repository":"java","rule":"S2698"},{"repository":"typescript","rule":"S109"},{"repository":"php","rule":"S1996"},{"repository":"php","rule":"S2964"},{"repository":"java","rule":"S2693"},{"repository":"java","rule":"S1120"},{"repository":"java","rule":"S2694"},{"repository":"java","rule":"S2211"},{"repository":"php","rule":"S1997"},{"repository":"java","rule":"S2333"},{"repository":"java","rule":"S1244"},{"repository":"typescript","rule":"S121"},{"repository":"java","rule":"S1151"},{"repository":"typescript","rule":"S122"},{"repository":"Web","rule":"IllegalElementCheck"},{"repository":"typescript","rule":"S126"},{"repository":"typescript","rule":"S4137"},{"repository":"java","rule":"S888"},{"repository":"javascript","rule":"S1525"},{"repository":"javascript","rule":"S1526"},{"repository":"typescript","rule":"S4139"},{"repository":"javascript","rule":"S1528"},{"repository":"javascript","rule":"S3827"},{"repository":"typescript","rule":"S4136"},{"repository":"java","rule":"S3578"},{"repository":"typescript","rule":"S117"},{"repository":"typescript","rule":"S131"},{"repository":"php","rule":"S2830"},{"repository":"typescript","rule":"S134"},{"repository":"typescript","rule":"S135"},{"repository":"typescript","rule":"S138"},{"repository":"typescript","rule":"S4023"},{"repository":"java","rule":"S881"},{"repository":"java","rule":"S5867"},{"repository":"java","rule":"S1147"},{"repository":"java","rule":"S1142"},{"repository":"php","rule":"S2701"}] - JDK_21__System_ diff --git a/plc4j/tools/malbec/malbec-core/ctx/pom.xml b/plc4j/tools/malbec/malbec-core/ctx/pom.xml index 07b46c0c9..47b0b39a2 100644 --- a/plc4j/tools/malbec/malbec-core/ctx/pom.xml +++ b/plc4j/tools/malbec/malbec-core/ctx/pom.xml @@ -1,13 +1,30 @@ + 4.0.0 org.apache.plc4x.malbec.core core - 0.13.0-SNAPSHOT + 1.1.0-SNAPSHOT - - org.apache.plc4x.malbec.core + ctx ${project.groupId}:${project.artifactId} nbm @@ -128,7 +145,7 @@ org.netbeans.api org-netbeans-libs-junit4 - RELEASE280 + ${netbeans.version} \ No newline at end of file diff --git a/plc4j/tools/malbec/malbec-core/ctx/src/main/resources/org/apache/plc4x/malbec/core/ctx/Bundle.properties b/plc4j/tools/malbec/malbec-core/ctx/src/main/resources/org/apache/plc4x/malbec/core/ctx/Bundle.properties index cd8a5e2bd..d17b78e16 100644 --- a/plc4j/tools/malbec/malbec-core/ctx/src/main/resources/org/apache/plc4x/malbec/core/ctx/Bundle.properties +++ b/plc4j/tools/malbec/malbec-core/ctx/src/main/resources/org/apache/plc4x/malbec/core/ctx/Bundle.properties @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + #Localized module labels. Defaults taken from POM (, , ) if unset. #OpenIDE-Module-Name= #OpenIDE-Module-Short-Description= diff --git a/plc4j/tools/malbec/malbec-core/libs/pom.xml b/plc4j/tools/malbec/malbec-core/libs/pom.xml index 08411caf8..3bea0668d 100644 --- a/plc4j/tools/malbec/malbec-core/libs/pom.xml +++ b/plc4j/tools/malbec/malbec-core/libs/pom.xml @@ -1,31 +1,30 @@ +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. +--> 4.0.0 org.apache.plc4x.malbec.core core - 0.13.0-SNAPSHOT + 1.1.0-SNAPSHOT - - org.apache.plc4x.malbec.core + libs ${project.groupId}:${project.artifactId} nbm @@ -36,13 +35,21 @@ org.apache.netbeans.utilities nbm-maven-plugin true - - - org.apache.plc4x.malbec.api - org.apache.xmlbeans - + + + org.apache.plc4x.* + org.apache.plc4x.malbec.api + org.apache.plc4x.malbec.api.s88 + org.apache.xmlbeans + org.apache.xmlbeans.impl.schema + org.apache.plc4x.malbec.metadata.system.schema + org.mesa.xml.b2MML + rockwell.areaModel + org.apache.commons.io + com.fasterxml.jackson.* + eager - + org.apache.maven.plugins @@ -53,10 +60,10 @@ - - org.apache.maven.plugins - maven-dependency-plugin - 3.1.2 + + org.apache.maven.plugins + maven-dependency-plugin + 3.1.2 check-dependencies @@ -67,8 +74,8 @@ false - - + + org.apache.maven.plugins maven-compiler-plugin @@ -95,7 +102,7 @@ org.apache.plc4x.malbec.metadata schema - + + + Area Model Schema - Copyright 2021 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/plc4j/tools/malbec/malbec-core/pom.xml b/plc4j/tools/malbec/malbec-core/pom.xml index 55e45c454..cc5dc7fd7 100644 --- a/plc4j/tools/malbec/malbec-core/pom.xml +++ b/plc4j/tools/malbec/malbec-core/pom.xml @@ -1,10 +1,28 @@ + 4.0.0 org.apache.plc4x malbec-parent - 0.13.0-SNAPSHOT + 1.1.0-SNAPSHOT org.apache.plc4x.malbec.core diff --git a/plc4j/tools/malbec/malbec-core/scheduler/pom.xml b/plc4j/tools/malbec/malbec-core/scheduler/pom.xml index 234bb993c..c1bdeb4a2 100644 --- a/plc4j/tools/malbec/malbec-core/scheduler/pom.xml +++ b/plc4j/tools/malbec/malbec-core/scheduler/pom.xml @@ -1,13 +1,30 @@ + 4.0.0 org.apache.plc4x.malbec.core malbec-core - 0.13.0-SNAPSHOT + 1.1.0-SNAPSHOT - - org.apache.plc4x.malbec.core + scheduler ${project.groupId}:${project.artifactId} nbm @@ -72,7 +89,7 @@ org.netbeans.api org-openide-util-lookup - RELEASE280 + ${netbeans.version} ${project.groupId} diff --git a/plc4j/tools/malbec/malbec-core/scheduler/src/main/java/org/apache/plc4x/malbec/core/scheduler/api/SchedulerError.java b/plc4j/tools/malbec/malbec-core/scheduler/src/main/java/org/apache/plc4x/malbec/core/scheduler/api/SchedulerError.java index 2d986f317..9d1f06e5a 100644 --- a/plc4j/tools/malbec/malbec-core/scheduler/src/main/java/org/apache/plc4x/malbec/core/scheduler/api/SchedulerError.java +++ b/plc4j/tools/malbec/malbec-core/scheduler/src/main/java/org/apache/plc4x/malbec/core/scheduler/api/SchedulerError.java @@ -1,18 +1,22 @@ /* - * Copyright 2017 The Apache Software Foundation. + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 * - * 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 * - * 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. + * 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 org.apache.plc4x.malbec.core.scheduler.api; public class SchedulerError extends Exception { diff --git a/plc4j/tools/malbec/malbec-core/scheduler/src/main/java/org/apache/plc4x/malbec/core/scheduler/core/Installer.java b/plc4j/tools/malbec/malbec-core/scheduler/src/main/java/org/apache/plc4x/malbec/core/scheduler/core/Installer.java index f25cb2194..72c1d8efe 100644 --- a/plc4j/tools/malbec/malbec-core/scheduler/src/main/java/org/apache/plc4x/malbec/core/scheduler/core/Installer.java +++ b/plc4j/tools/malbec/malbec-core/scheduler/src/main/java/org/apache/plc4x/malbec/core/scheduler/core/Installer.java @@ -1,7 +1,22 @@ /* - * Click nbfs://nbhost/SystemFileSystem/Templates/Licenses/license-default.txt to change this license - * Click nbfs://nbhost/SystemFileSystem/Templates/NetBeansModuleDevelopment-files/moduleInstall.java to edit this template + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. */ + package org.apache.plc4x.malbec.core.scheduler.core; import org.openide.modules.ModuleInstall; diff --git a/plc4j/tools/malbec/malbec-core/scheduler/src/main/resources/org/apache/plc4x/malbec/core/scheduler/Bundle.properties b/plc4j/tools/malbec/malbec-core/scheduler/src/main/resources/org/apache/plc4x/malbec/core/scheduler/Bundle.properties index 756a322b4..380cf50e4 100644 --- a/plc4j/tools/malbec/malbec-core/scheduler/src/main/resources/org/apache/plc4x/malbec/core/scheduler/Bundle.properties +++ b/plc4j/tools/malbec/malbec-core/scheduler/src/main/resources/org/apache/plc4x/malbec/core/scheduler/Bundle.properties @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + #Localized module labels. Defaults taken from POM (, , ) if unset. #OpenIDE-Module-Name= #OpenIDE-Module-Short-Description= diff --git a/plc4j/tools/malbec/malbec-plc4x/eip/pom.xml b/plc4j/tools/malbec/malbec-plc4x/eip/pom.xml index fd7132587..74fcfe9ae 100644 --- a/plc4j/tools/malbec/malbec-plc4x/eip/pom.xml +++ b/plc4j/tools/malbec/malbec-plc4x/eip/pom.xml @@ -1,13 +1,30 @@ + 4.0.0 org.apache.plc4x.malbec.plc4x plc4x - 0.13.0-SNAPSHOT + 1.1.0-SNAPSHOT - - org.apache.plc4x.malbec.plc4x + eip ${project.groupId}:${project.artifactId} nbm @@ -106,10 +123,123 @@ plc4j-driver-eip ${plc4x.version} + + org.slf4j + slf4j-api + ${slf4j.version} + + + org.apache.plc4x + plc4j-api + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-spi-buffers-api + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-spi-buffers-byte + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-spi-config + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-spi-drivers + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-spi-fields + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-spi-utils + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-spi-values + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-transports-api + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-transports-cotp + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-transports-serial + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-transports-tcp + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-transports-tls + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-transports-udp + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-tools-event-pump + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-utils-audit-log-api + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-utils-raw-sockets + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-utils-subscription-emulation + ${plc4x.version} + provided + org.apache.plc4x.malbec.core libs - 0.13.0-SNAPSHOT + ${project.version} \ No newline at end of file diff --git a/plc4j/tools/malbec/malbec-plc4x/eip/src/main/resources/org/apache/plc4x/malbec/eip/Bundle.properties b/plc4j/tools/malbec/malbec-plc4x/eip/src/main/resources/org/apache/plc4x/malbec/eip/Bundle.properties index b83cb6218..1bc19f5f8 100644 --- a/plc4j/tools/malbec/malbec-plc4x/eip/src/main/resources/org/apache/plc4x/malbec/eip/Bundle.properties +++ b/plc4j/tools/malbec/malbec-plc4x/eip/src/main/resources/org/apache/plc4x/malbec/eip/Bundle.properties @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + #Localized module labels. Defaults taken from POM (, , ) if unset. #OpenIDE-Module-Name= #OpenIDE-Module-Short-Description= diff --git a/plc4j/tools/malbec/malbec-plc4x/modbus/pom.xml b/plc4j/tools/malbec/malbec-plc4x/modbus/pom.xml index b5d0beb74..344229f00 100644 --- a/plc4j/tools/malbec/malbec-plc4x/modbus/pom.xml +++ b/plc4j/tools/malbec/malbec-plc4x/modbus/pom.xml @@ -22,10 +22,9 @@ org.apache.plc4x.malbec.plc4x plc4x - 0.13.0-SNAPSHOT + 1.1.0-SNAPSHOT - - org.apache.plc4x.malbec.plc4x + modbus ${project.groupId}:${project.artifactId} nbm @@ -121,10 +120,123 @@ plc4j-driver-modbus ${plc4x.version} + + org.slf4j + slf4j-api + ${slf4j.version} + + + org.apache.plc4x + plc4j-api + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-spi-buffers-api + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-spi-buffers-byte + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-spi-config + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-spi-drivers + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-spi-fields + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-spi-utils + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-spi-values + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-transports-api + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-transports-cotp + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-transports-serial + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-transports-tcp + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-transports-tls + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-transports-udp + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-tools-event-pump + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-utils-audit-log-api + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-utils-raw-sockets + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-utils-subscription-emulation + ${plc4x.version} + provided + org.apache.plc4x.malbec.core libs - 0.13.0-SNAPSHOT + ${project.version}
\ No newline at end of file diff --git a/plc4j/tools/malbec/malbec-plc4x/modbus/src/main/resources/org/apache/plc4x/malbec/modbus/Bundle.properties b/plc4j/tools/malbec/malbec-plc4x/modbus/src/main/resources/org/apache/plc4x/malbec/modbus/Bundle.properties index 5dc430e68..ebc3c34e1 100644 --- a/plc4j/tools/malbec/malbec-plc4x/modbus/src/main/resources/org/apache/plc4x/malbec/modbus/Bundle.properties +++ b/plc4j/tools/malbec/malbec-plc4x/modbus/src/main/resources/org/apache/plc4x/malbec/modbus/Bundle.properties @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + #Localized module labels. Defaults taken from POM (, , ) if unset. #OpenIDE-Module-Name= #OpenIDE-Module-Short-Description= diff --git a/plc4j/tools/malbec/malbec-plc4x/modbus/src/main/resources/org/apache/plc4x/malbec/modbus/core/Bundle.properties b/plc4j/tools/malbec/malbec-plc4x/modbus/src/main/resources/org/apache/plc4x/malbec/modbus/core/Bundle.properties index c0adb27a6..4a9ee2cc6 100644 --- a/plc4j/tools/malbec/malbec-plc4x/modbus/src/main/resources/org/apache/plc4x/malbec/modbus/core/Bundle.properties +++ b/plc4j/tools/malbec/malbec-plc4x/modbus/src/main/resources/org/apache/plc4x/malbec/modbus/core/Bundle.properties @@ -1 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + LBL_Device=Device_3\n diff --git a/plc4j/tools/malbec/malbec-plc4x/pom.xml b/plc4j/tools/malbec/malbec-plc4x/pom.xml index 3bad7917f..43da52647 100644 --- a/plc4j/tools/malbec/malbec-plc4x/pom.xml +++ b/plc4j/tools/malbec/malbec-plc4x/pom.xml @@ -1,10 +1,28 @@ + 4.0.0 org.apache.plc4x malbec-parent - 0.13.0-SNAPSHOT + 1.1.0-SNAPSHOT org.apache.plc4x.malbec.plc4x diff --git a/plc4j/tools/malbec/malbec-plc4x/s7/pom.xml b/plc4j/tools/malbec/malbec-plc4x/s7/pom.xml index 698a1b558..06171322f 100644 --- a/plc4j/tools/malbec/malbec-plc4x/s7/pom.xml +++ b/plc4j/tools/malbec/malbec-plc4x/s7/pom.xml @@ -22,10 +22,9 @@ org.apache.plc4x.malbec.plc4x plc4x - 0.13.0-SNAPSHOT + 1.1.0-SNAPSHOT - - org.apache.plc4x.malbec.plc4x + s7 ${project.groupId}:${project.artifactId} nbm @@ -121,10 +120,118 @@ plc4j-driver-s7 ${plc4x.version} + + org.apache.plc4x + plc4j-api + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-spi-buffers-api + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-spi-buffers-byte + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-spi-config + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-spi-drivers + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-spi-fields + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-spi-utils + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-spi-values + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-transports-api + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-transports-cotp + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-transports-serial + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-transports-tcp + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-transports-tls + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-transports-udp + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-tools-event-pump + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-utils-audit-log-api + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-utils-raw-sockets + ${plc4x.version} + provided + + + org.apache.plc4x + plc4j-utils-subscription-emulation + ${plc4x.version} + provided + org.apache.plc4x.malbec.core libs - 0.13.0-SNAPSHOT + ${project.version} \ No newline at end of file diff --git a/plc4j/tools/malbec/malbec-plc4x/s7/src/main/resources/org/apache/plc4x/malbec/s7/Bundle.properties b/plc4j/tools/malbec/malbec-plc4x/s7/src/main/resources/org/apache/plc4x/malbec/s7/Bundle.properties index f2bb86ec2..630b96fb3 100644 --- a/plc4j/tools/malbec/malbec-plc4x/s7/src/main/resources/org/apache/plc4x/malbec/s7/Bundle.properties +++ b/plc4j/tools/malbec/malbec-plc4x/s7/src/main/resources/org/apache/plc4x/malbec/s7/Bundle.properties @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + #Localized module labels. Defaults taken from POM (, , ) if unset. #OpenIDE-Module-Name= #OpenIDE-Module-Short-Description= diff --git a/plc4j/tools/malbec/malbec-plc4x/services/pom.xml b/plc4j/tools/malbec/malbec-plc4x/services/pom.xml index b42694c5b..55c3e2b60 100644 --- a/plc4j/tools/malbec/malbec-plc4x/services/pom.xml +++ b/plc4j/tools/malbec/malbec-plc4x/services/pom.xml @@ -22,10 +22,9 @@ org.apache.plc4x.malbec.plc4x plc4x - 0.13.0-SNAPSHOT + 1.1.0-SNAPSHOT - - org.apache.plc4x.malbec.plc4x + services ${project.groupId}:${project.artifactId} nbm @@ -159,6 +158,7 @@ org.apache.plc4x plc4j-api ${plc4x.version} + provided org.netbeans.api @@ -183,7 +183,7 @@ org.apache.plc4x.malbec.core libs - 0.13.0-SNAPSHOT + ${project.version}
\ No newline at end of file diff --git a/plc4j/tools/malbec/malbec-plc4x/services/src/main/java/org/apache/plc4x/malbec/services/model/Bundle.properties b/plc4j/tools/malbec/malbec-plc4x/services/src/main/java/org/apache/plc4x/malbec/services/model/Bundle.properties index a4a6fc444..54bb000d9 100644 --- a/plc4j/tools/malbec/malbec-plc4x/services/src/main/java/org/apache/plc4x/malbec/services/model/Bundle.properties +++ b/plc4j/tools/malbec/malbec-plc4x/services/src/main/java/org/apache/plc4x/malbec/services/model/Bundle.properties @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + Plc4xAddDeviceDialog.jButton1.text=jButton1 Plc4xAddDeviceDialog.jButton2.text=jButton2 Plc4xAddDeviceDialog.title=Add Device diff --git a/plc4j/tools/malbec/malbec-plc4x/services/src/main/resources/org/apache/plc4x/malbec/services/Bundle.properties b/plc4j/tools/malbec/malbec-plc4x/services/src/main/resources/org/apache/plc4x/malbec/services/Bundle.properties index 1c9e9996c..c844219cd 100644 --- a/plc4j/tools/malbec/malbec-plc4x/services/src/main/resources/org/apache/plc4x/malbec/services/Bundle.properties +++ b/plc4j/tools/malbec/malbec-plc4x/services/src/main/resources/org/apache/plc4x/malbec/services/Bundle.properties @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + #Localized module labels. Defaults taken from POM (, , ) if unset. #OpenIDE-Module-Name= #OpenIDE-Module-Short-Description= diff --git a/plc4j/tools/malbec/malbec-plc4x/services/src/main/resources/org/apache/plc4x/malbec/services/core/Bundle.properties b/plc4j/tools/malbec/malbec-plc4x/services/src/main/resources/org/apache/plc4x/malbec/services/core/Bundle.properties index a4a6fc444..54bb000d9 100644 --- a/plc4j/tools/malbec/malbec-plc4x/services/src/main/resources/org/apache/plc4x/malbec/services/core/Bundle.properties +++ b/plc4j/tools/malbec/malbec-plc4x/services/src/main/resources/org/apache/plc4x/malbec/services/core/Bundle.properties @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + Plc4xAddDeviceDialog.jButton1.text=jButton1 Plc4xAddDeviceDialog.jButton2.text=jButton2 Plc4xAddDeviceDialog.title=Add Device diff --git a/plc4j/tools/malbec/malbec-plc4x/services/src/main/resources/org/apache/plc4x/malbec/services/model/Bundle.properties b/plc4j/tools/malbec/malbec-plc4x/services/src/main/resources/org/apache/plc4x/malbec/services/model/Bundle.properties index a4a6fc444..54bb000d9 100644 --- a/plc4j/tools/malbec/malbec-plc4x/services/src/main/resources/org/apache/plc4x/malbec/services/model/Bundle.properties +++ b/plc4j/tools/malbec/malbec-plc4x/services/src/main/resources/org/apache/plc4x/malbec/services/model/Bundle.properties @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + Plc4xAddDeviceDialog.jButton1.text=jButton1 Plc4xAddDeviceDialog.jButton2.text=jButton2 Plc4xAddDeviceDialog.title=Add Device diff --git a/plc4j/tools/malbec/malbec-plc4x/services/src/main/resources/org/apache/plc4x/malbec/services/view/Bundle.properties b/plc4j/tools/malbec/malbec-plc4x/services/src/main/resources/org/apache/plc4x/malbec/services/view/Bundle.properties index 0657a7bb2..3ee833ae8 100644 --- a/plc4j/tools/malbec/malbec-plc4x/services/src/main/resources/org/apache/plc4x/malbec/services/view/Bundle.properties +++ b/plc4j/tools/malbec/malbec-plc4x/services/src/main/resources/org/apache/plc4x/malbec/services/view/Bundle.properties @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + # Click nbfs://nbhost/SystemFileSystem/Templates/Licenses/license-default.txt to change this license # Click nbfs://nbhost/SystemFileSystem/Templates/Other/properties.properties to edit this template diff --git a/plc4j/tools/malbec/malbec-projecttype/comms/pom.xml b/plc4j/tools/malbec/malbec-projecttype/comms/pom.xml index f0c2b8b67..6f6438c5f 100644 --- a/plc4j/tools/malbec/malbec-projecttype/comms/pom.xml +++ b/plc4j/tools/malbec/malbec-projecttype/comms/pom.xml @@ -1,13 +1,30 @@ + 4.0.0 org.apache.plc4x.malbec.projecttype projecttype - 0.13.0-SNAPSHOT + 1.1.0-SNAPSHOT - - org.apache.plc4x.malbec.projecttype + comms ${project.groupId}:${project.artifactId} nbm diff --git a/plc4j/tools/malbec/malbec-projecttype/comms/src/main/resources/org/apache/plc4x/malbec/comms/impl/Bundle.properties b/plc4j/tools/malbec/malbec-projecttype/comms/src/main/resources/org/apache/plc4x/malbec/comms/impl/Bundle.properties index 5c086c8e9..a2f53e38e 100644 --- a/plc4j/tools/malbec/malbec-projecttype/comms/src/main/resources/org/apache/plc4x/malbec/comms/impl/Bundle.properties +++ b/plc4j/tools/malbec/malbec-projecttype/comms/src/main/resources/org/apache/plc4x/malbec/comms/impl/Bundle.properties @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + #Localized module labels. Defaults taken from POM (, , ) if unset. #OpenIDE-Module-Name= #OpenIDE-Module-Short-Description= diff --git a/plc4j/tools/malbec/malbec-projecttype/events/pom.xml b/plc4j/tools/malbec/malbec-projecttype/events/pom.xml index 7eb9fc76d..a85455c08 100644 --- a/plc4j/tools/malbec/malbec-projecttype/events/pom.xml +++ b/plc4j/tools/malbec/malbec-projecttype/events/pom.xml @@ -1,13 +1,30 @@ + 4.0.0 org.apache.plc4x.malbec.projecttype projecttype - 0.13.0-SNAPSHOT + 1.1.0-SNAPSHOT - - org.apache.plc4x.malbec.projecttype + events ${project.groupId}:${project.artifactId} nbm diff --git a/plc4j/tools/malbec/malbec-projecttype/events/src/main/resources/org/apache/plc4x/malbec/events/impl/Bundle.properties b/plc4j/tools/malbec/malbec-projecttype/events/src/main/resources/org/apache/plc4x/malbec/events/impl/Bundle.properties index c95ba7e92..458f1dffd 100644 --- a/plc4j/tools/malbec/malbec-projecttype/events/src/main/resources/org/apache/plc4x/malbec/events/impl/Bundle.properties +++ b/plc4j/tools/malbec/malbec-projecttype/events/src/main/resources/org/apache/plc4x/malbec/events/impl/Bundle.properties @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + #Localized module labels. Defaults taken from POM (, , ) if unset. #OpenIDE-Module-Name= #OpenIDE-Module-Short-Description= diff --git a/plc4j/tools/malbec/malbec-projecttype/hmi/nb-configuration.xml b/plc4j/tools/malbec/malbec-projecttype/hmi/nb-configuration.xml index eaca9690d..1abd1d461 100644 --- a/plc4j/tools/malbec/malbec-projecttype/hmi/nb-configuration.xml +++ b/plc4j/tools/malbec/malbec-projecttype/hmi/nb-configuration.xml @@ -17,6 +17,5 @@ Any value defined here will override the pom.xml file value but is only applicab global {} true - JDK_21__System_ diff --git a/plc4j/tools/malbec/malbec-projecttype/hmi/pom.xml b/plc4j/tools/malbec/malbec-projecttype/hmi/pom.xml index daee33f03..3d0776a88 100644 --- a/plc4j/tools/malbec/malbec-projecttype/hmi/pom.xml +++ b/plc4j/tools/malbec/malbec-projecttype/hmi/pom.xml @@ -1,13 +1,30 @@ + 4.0.0 org.apache.plc4x.malbec.projecttype projecttype - 0.13.0-SNAPSHOT + 1.1.0-SNAPSHOT - - org.apache.plc4x.malbec.projecttype + hmi ${project.groupId}:${project.artifactId} nbm diff --git a/plc4j/tools/malbec/malbec-projecttype/hmi/src/main/java/org/apache/plc4x/malbec/hmi/impl/Plc4xHMISubProjectLogicalViewProviderImpl.java b/plc4j/tools/malbec/malbec-projecttype/hmi/src/main/java/org/apache/plc4x/malbec/hmi/impl/Plc4xHMISubProjectLogicalViewProviderImpl.java index b0f39f3a4..2bc93995b 100644 --- a/plc4j/tools/malbec/malbec-projecttype/hmi/src/main/java/org/apache/plc4x/malbec/hmi/impl/Plc4xHMISubProjectLogicalViewProviderImpl.java +++ b/plc4j/tools/malbec/malbec-projecttype/hmi/src/main/java/org/apache/plc4x/malbec/hmi/impl/Plc4xHMISubProjectLogicalViewProviderImpl.java @@ -24,6 +24,7 @@ import org.netbeans.api.project.Project; import org.netbeans.spi.project.ui.LogicalViewProvider; import org.netbeans.spi.project.ui.support.CommonProjectActions; +import org.netbeans.spi.project.ui.support.NodeFactorySupport; import org.openide.filesystems.FileObject; import org.openide.loaders.DataFolder; import org.openide.loaders.DataObjectNotFoundException; @@ -79,7 +80,7 @@ private final class ProjectNode extends FilterNode { public ProjectNode(Node node, Project project) throws DataObjectNotFoundException { super(node, - new FilterNode.Children(node), + NodeFactorySupport.createCompositeChildren(project, "Projects/org-plc4x-hmi-project/Nodes"), new ProxyLookup( new Lookup[]{ Lookups.singleton(project), diff --git a/plc4j/tools/malbec/malbec-projecttype/hmi/src/main/java/org/apache/plc4x/malbec/hmi/impl/Plc4xHMISubProjectProviderImpl.java b/plc4j/tools/malbec/malbec-projecttype/hmi/src/main/java/org/apache/plc4x/malbec/hmi/impl/Plc4xHMISubProjectProviderImpl.java index 73993f6eb..6ede0d172 100644 --- a/plc4j/tools/malbec/malbec-projecttype/hmi/src/main/java/org/apache/plc4x/malbec/hmi/impl/Plc4xHMISubProjectProviderImpl.java +++ b/plc4j/tools/malbec/malbec-projecttype/hmi/src/main/java/org/apache/plc4x/malbec/hmi/impl/Plc4xHMISubProjectProviderImpl.java @@ -68,15 +68,17 @@ public Set loadProjects(FileObject dir) { Set newProjects = new HashSet(); Project subp = null; FileObject reportsFolder = dir.getFileObject(HMI_SUBPROJECT_DIRECTORY); - try { - subp = ProjectManager.getDefault().findProject(reportsFolder); - if (subp != null && subp instanceof Plc4xHMISubProjectImpl) { - newProjects.add((Plc4xHMISubProjectImpl) subp); - } - } catch (IOException ex) { - Exceptions.printStackTrace(ex); - } catch (IllegalArgumentException ex) { - Exceptions.printStackTrace(ex); + if (reportsFolder != null) { + try { + subp = ProjectManager.getDefault().findProject(reportsFolder); + if (subp != null && subp instanceof Plc4xHMISubProjectImpl) { + newProjects.add((Plc4xHMISubProjectImpl) subp); + } + } catch (IOException ex) { + Exceptions.printStackTrace(ex); + } catch (IllegalArgumentException ex) { + Exceptions.printStackTrace(ex); + } } diff --git a/plc4j/tools/malbec/malbec-projecttype/hmi/src/main/resources/org/apache/plc4x/malbec/hmi/Bundle.properties b/plc4j/tools/malbec/malbec-projecttype/hmi/src/main/resources/org/apache/plc4x/malbec/hmi/Bundle.properties index e1d609572..1c1cdbbbb 100644 --- a/plc4j/tools/malbec/malbec-projecttype/hmi/src/main/resources/org/apache/plc4x/malbec/hmi/Bundle.properties +++ b/plc4j/tools/malbec/malbec-projecttype/hmi/src/main/resources/org/apache/plc4x/malbec/hmi/Bundle.properties @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + #Localized module labels. Defaults taken from POM (, , ) if unset. #OpenIDE-Module-Name= #OpenIDE-Module-Short-Description= diff --git a/plc4j/tools/malbec/malbec-projecttype/htc/pom.xml b/plc4j/tools/malbec/malbec-projecttype/htc/pom.xml index 13c2e5814..4f7976514 100644 --- a/plc4j/tools/malbec/malbec-projecttype/htc/pom.xml +++ b/plc4j/tools/malbec/malbec-projecttype/htc/pom.xml @@ -1,13 +1,30 @@ + 4.0.0 org.apache.plc4x.malbec.projecttype projecttype - 0.13.0-SNAPSHOT + 1.1.0-SNAPSHOT - - org.apache.plc4x.malbec.projecttype + htc ${project.groupId}:${project.artifactId} nbm diff --git a/plc4j/tools/malbec/malbec-projecttype/htc/src/main/resources/org/apache/plc4x/malbec/htc/impl/Bundle.properties b/plc4j/tools/malbec/malbec-projecttype/htc/src/main/resources/org/apache/plc4x/malbec/htc/impl/Bundle.properties index 98403f544..b97605894 100644 --- a/plc4j/tools/malbec/malbec-projecttype/htc/src/main/resources/org/apache/plc4x/malbec/htc/impl/Bundle.properties +++ b/plc4j/tools/malbec/malbec-projecttype/htc/src/main/resources/org/apache/plc4x/malbec/htc/impl/Bundle.properties @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + #Localized module labels. Defaults taken from POM (, , ) if unset. #OpenIDE-Module-Name= #OpenIDE-Module-Short-Description= diff --git a/plc4j/tools/malbec/malbec-projecttype/information/pom.xml b/plc4j/tools/malbec/malbec-projecttype/information/pom.xml index c41fd1101..b768f07be 100644 --- a/plc4j/tools/malbec/malbec-projecttype/information/pom.xml +++ b/plc4j/tools/malbec/malbec-projecttype/information/pom.xml @@ -1,13 +1,30 @@ + 4.0.0 org.apache.plc4x.malbec.projecttype projecttype - 0.13.0-SNAPSHOT + 1.1.0-SNAPSHOT - - org.apache.plc4x.malbec.projecttype + information ${project.groupId}:${project.artifactId} nbm diff --git a/plc4j/tools/malbec/malbec-projecttype/information/src/main/resources/org/apache/plc4x/malbec/information/Bundle.properties b/plc4j/tools/malbec/malbec-projecttype/information/src/main/resources/org/apache/plc4x/malbec/information/Bundle.properties index 46d7079ba..1d32399bb 100644 --- a/plc4j/tools/malbec/malbec-projecttype/information/src/main/resources/org/apache/plc4x/malbec/information/Bundle.properties +++ b/plc4j/tools/malbec/malbec-projecttype/information/src/main/resources/org/apache/plc4x/malbec/information/Bundle.properties @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + #Localized module labels. Defaults taken from POM (, , ) if unset. #OpenIDE-Module-Name= #OpenIDE-Module-Short-Description= diff --git a/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/pom.xml b/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/pom.xml index 19520331e..a0b4c4a6a 100644 --- a/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/pom.xml +++ b/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/pom.xml @@ -1,13 +1,30 @@ + 4.0.0 org.apache.plc4x.malbec.projecttype projecttype - 0.13.0-SNAPSHOT + 1.1.0-SNAPSHOT - - org.apache.plc4x.malbec.projecttype + type ${project.groupId}:${project.artifactId} nbm @@ -131,5 +148,45 @@ hmi ${project.version} + + ${project.groupId} + scripts + ${project.version} + + + ${project.groupId} + information + ${project.version} + + + ${project.groupId} + tgl + ${project.version} + + + ${project.groupId} + securities + ${project.version} + + + ${project.groupId} + udt + ${project.version} + + + ${project.groupId} + pics + ${project.version} + + + org.apache.plc4x.malbec.s88 + plant + ${project.version} + + + ${project.groupId} + htc + ${project.version} + \ No newline at end of file diff --git a/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/java/org/apache/plc4x/malbec/projecttype/core/Plc4xProjectPanelVisual.java b/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/java/org/apache/plc4x/malbec/projecttype/core/Plc4xProjectPanelVisual.java index 5b4cd4c2e..e51c60125 100644 --- a/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/java/org/apache/plc4x/malbec/projecttype/core/Plc4xProjectPanelVisual.java +++ b/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/java/org/apache/plc4x/malbec/projecttype/core/Plc4xProjectPanelVisual.java @@ -1,7 +1,22 @@ /* - * Click nbfs://nbhost/SystemFileSystem/Templates/Licenses/license-default.txt to change this license - * Click nbfs://nbhost/SystemFileSystem/Templates/NetBeansModuleDevelopment-files/templatePanelVisual.java to edit this template + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. */ + package org.apache.plc4x.malbec.projecttype.core; import java.io.File; diff --git a/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/java/org/apache/plc4x/malbec/projecttype/core/Plc4xProjectWizardIterator.java b/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/java/org/apache/plc4x/malbec/projecttype/core/Plc4xProjectWizardIterator.java index e7f80b8f3..be14544dd 100644 --- a/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/java/org/apache/plc4x/malbec/projecttype/core/Plc4xProjectWizardIterator.java +++ b/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/java/org/apache/plc4x/malbec/projecttype/core/Plc4xProjectWizardIterator.java @@ -51,8 +51,8 @@ import org.xml.sax.InputSource; // TODO define position attribute -@TemplateRegistration(folder = "Project/Samples", displayName = "#Plc4xProject_displayName", description = "Plc4xProjectDescription.html", iconBase = "org/apache/plc4x/malbec/projecttype/core/Plc4xProject.png", content = "Plc4xProjectProject.zip") -@Messages("Plc4xProject_displayName=Plc4xProject") +@TemplateRegistration(folder = "Project/PLC4X", displayName = "#Plc4xProject_displayName", description = "Plc4xProjectDescription.html", iconBase = "org/apache/plc4x/malbec/projecttype/core/Plc4xProject.png", content = "Plc4xProjectProject.zip", position = 20 ) +@Messages("Plc4xProject_displayName=Plc4x Standard Project") public class Plc4xProjectWizardIterator implements WizardDescriptor./*Progress*/InstantiatingIterator { private int index; @@ -86,17 +86,23 @@ private String[] createSteps() { FileObject dir = FileUtil.toFileObject(dirF); unZipFile(template.getInputStream(), dir); - // Always open top dir as a project: - resultSet.add(dir); - // Look for nested projects to open as well: - Enumeration e = dir.getFolders(true); - while (e.hasMoreElements()) { - FileObject subfolder = e.nextElement(); - if (ProjectManager.getDefault().isProject(subfolder)) { - resultSet.add(subfolder); - } + + if (dir.getFileObject("config.cfg") == null) { + dir.createData("config.cfg"); } + + String[] rootFolders = {"hmi", "recipes", "comms", "events", "scripts", "information", "tgl", "securities", "udt"}; + for (String folder : rootFolders) { + if (dir.getFileObject(folder) == null) { + dir.createFolder(folder); + } + } + + + + resultSet.add(dir); + File parent = dirF.getParentFile(); if (parent != null && parent.exists()) { ProjectChooser.setProjectsFolder(parent); diff --git a/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/java/org/apache/plc4x/malbec/projecttype/impl/FolderInstaller.java b/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/java/org/apache/plc4x/malbec/projecttype/impl/FolderInstaller.java new file mode 100644 index 000000000..91208e87e --- /dev/null +++ b/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/java/org/apache/plc4x/malbec/projecttype/impl/FolderInstaller.java @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.plc4x.malbec.projecttype.impl; + +import org.netbeans.spi.project.ui.support.ProjectChooser; +import org.openide.modules.OnStart; + +import java.io.File; + +@OnStart +public class FolderInstaller implements Runnable { + @Override + public void run() { + File dir = new File(System.getProperty("user.home"), "MalbecProjects"); + + if(!dir.exists()) { + dir.mkdirs(); + } + + ProjectChooser.setProjectsFolder(dir); + } +} diff --git a/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/java/org/apache/plc4x/malbec/projecttype/impl/Plc4xCustomizerProviderImpl.java b/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/java/org/apache/plc4x/malbec/projecttype/impl/Plc4xCustomizerProviderImpl.java index e1ae9264f..75f7f291c 100644 --- a/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/java/org/apache/plc4x/malbec/projecttype/impl/Plc4xCustomizerProviderImpl.java +++ b/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/java/org/apache/plc4x/malbec/projecttype/impl/Plc4xCustomizerProviderImpl.java @@ -35,7 +35,7 @@ public class Plc4xCustomizerProviderImpl implements CustomizerProvider2 { "Projects/org-plc4x-project/Customizer"; public Plc4xCustomizerProviderImpl(Plc4xProjectImpl project) { - this.project = null; + this.project = project; } @Override diff --git a/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/java/org/apache/plc4x/malbec/projecttype/impl/Plc4xProjectImpl.java b/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/java/org/apache/plc4x/malbec/projecttype/impl/Plc4xProjectImpl.java index fc2f9fe09..7ebb4de24 100644 --- a/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/java/org/apache/plc4x/malbec/projecttype/impl/Plc4xProjectImpl.java +++ b/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/java/org/apache/plc4x/malbec/projecttype/impl/Plc4xProjectImpl.java @@ -21,7 +21,11 @@ import java.beans.PropertyChangeListener; import javax.swing.Icon; import javax.swing.ImageIcon; +import org.apache.plc4x.malbec.comms.impl.Plc4xCommsSubProjectProviderImpl; +import org.apache.plc4x.malbec.events.impl.Plc4xEventsSubProjectProviderImpl; import org.apache.plc4x.malbec.hmi.impl.Plc4xHMISubProjectProviderImpl; +import org.apache.plc4x.malbec.recipes.impl.Plc4xRecipesSubProjectProviderImpl; +import org.apache.plc4x.malbec.s88.plant.impl.Plc4xPlantSubProjectProviderImpl; import org.apache.plc4x.malbec.projecttype.panels.Plc4xGeneralPropertiesImpl; import org.netbeans.api.annotations.common.StaticResource; import org.netbeans.api.project.Project; @@ -60,10 +64,10 @@ public Lookup getLookup() { new Plc4xCustomizerProviderImpl(this), new Plc4xGeneralPropertiesImpl(), new Plc4xHMISubProjectProviderImpl(this), -// new Plc4xCommsSubProjectProviderImpl(this), -// new Plc4xEventsSubProjectProviderImpl(this), -// new Plc4xRecipesSubProjectProviderImpl(this), - + new Plc4xCommsSubProjectProviderImpl(this), + new Plc4xEventsSubProjectProviderImpl(this), + new Plc4xRecipesSubProjectProviderImpl(this), + new Plc4xPlantSubProjectProviderImpl(this), }); } return lkp; diff --git a/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/java/org/apache/plc4x/malbec/projecttype/impl/Plc4xProjectLogicalViewImpl.java b/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/java/org/apache/plc4x/malbec/projecttype/impl/Plc4xProjectLogicalViewImpl.java index 72161c250..9dd8c1583 100644 --- a/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/java/org/apache/plc4x/malbec/projecttype/impl/Plc4xProjectLogicalViewImpl.java +++ b/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/java/org/apache/plc4x/malbec/projecttype/impl/Plc4xProjectLogicalViewImpl.java @@ -19,7 +19,10 @@ package org.apache.plc4x.malbec.projecttype.impl; import java.awt.Image; +import java.util.ArrayList; +import java.util.List; import javax.swing.Action; + import org.netbeans.api.annotations.common.StaticResource; import org.netbeans.spi.project.ui.LogicalViewProvider; import org.netbeans.spi.project.ui.support.CommonProjectActions; @@ -34,6 +37,7 @@ import org.openide.util.Exceptions; import org.openide.util.ImageUtilities; import org.openide.util.Lookup; +import org.openide.util.Utilities; import org.openide.util.lookup.Lookups; import org.openide.util.lookup.ProxyLookup; @@ -92,13 +96,16 @@ public ProjectNode(Node node, Plc4xProjectImpl project) @Override public Action[] getActions(boolean arg0) { - return new Action[]{ - CommonProjectActions.newFileAction(), - CommonProjectActions.copyProjectAction(), - CommonProjectActions.deleteProjectAction(), - CommonProjectActions.closeProjectAction(), - CommonProjectActions.customizeProjectAction() - }; + List actions = new ArrayList<>(); + actions.add(CommonProjectActions.newFileAction()); + actions.addAll(Utilities.actionsForPath("Projects/org-plc4x-project/Actions")); + actions.add(null); // Separator + actions.add(CommonProjectActions.copyProjectAction()); + actions.add(CommonProjectActions.deleteProjectAction()); + actions.add(CommonProjectActions.closeProjectAction()); + actions.add(null); // Separator + actions.add(CommonProjectActions.customizeProjectAction()); + return actions.toArray(new Action[0]); } @Override diff --git a/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/java/org/apache/plc4x/malbec/projecttype/panels/Plc4xGeneralPropertiesImpl.java b/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/java/org/apache/plc4x/malbec/projecttype/panels/Plc4xGeneralPropertiesImpl.java index 558be2b25..42012b1cb 100644 --- a/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/java/org/apache/plc4x/malbec/projecttype/panels/Plc4xGeneralPropertiesImpl.java +++ b/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/java/org/apache/plc4x/malbec/projecttype/panels/Plc4xGeneralPropertiesImpl.java @@ -29,7 +29,7 @@ public class Plc4xGeneralPropertiesImpl implements ProjectCustomizer.CompositeC private static final String GENERAL = "General"; - @ProjectCustomizer.CompositeCategoryProvider.Registration(projectType = "org-customer-project", position = 10) + @ProjectCustomizer.CompositeCategoryProvider.Registration(projectType = "org-plc4x-project", position = 10) public static Plc4xGeneralPropertiesImpl createGeneral() { return new Plc4xGeneralPropertiesImpl(); } diff --git a/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/resources/org/apache/plc4x/malbec/projecttype/Bundle.properties b/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/resources/org/apache/plc4x/malbec/projecttype/Bundle.properties index 23d9750e1..51e04edae 100644 --- a/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/resources/org/apache/plc4x/malbec/projecttype/Bundle.properties +++ b/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/resources/org/apache/plc4x/malbec/projecttype/Bundle.properties @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + #Localized module labels. Defaults taken from POM (, , ) if unset. #OpenIDE-Module-Name= #OpenIDE-Module-Short-Description= diff --git a/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/resources/org/apache/plc4x/malbec/projecttype/core/Bundle.properties b/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/resources/org/apache/plc4x/malbec/projecttype/core/Bundle.properties index 03bad7741..5e97627f0 100644 --- a/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/resources/org/apache/plc4x/malbec/projecttype/core/Bundle.properties +++ b/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/resources/org/apache/plc4x/malbec/projecttype/core/Bundle.properties @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + LBL_CreateProjectStep=Name and Location Plc4xProjectPanelVisual.projectNameLabel.text=Project &Name: Plc4xProjectPanelVisual.createdFolderLabel.text=Project &Folder: diff --git a/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/resources/org/apache/plc4x/malbec/projecttype/core/Plc4xProjectDescription.html b/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/resources/org/apache/plc4x/malbec/projecttype/core/Plc4xProjectDescription.html index 7422bcc3a..c36cf87a4 100644 --- a/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/resources/org/apache/plc4x/malbec/projecttype/core/Plc4xProjectDescription.html +++ b/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/resources/org/apache/plc4x/malbec/projecttype/core/Plc4xProjectDescription.html @@ -1,7 +1,22 @@ + diff --git a/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/resources/org/apache/plc4x/malbec/projecttype/core/Plc4xProjectProject.zip b/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/resources/org/apache/plc4x/malbec/projecttype/core/Plc4xProjectProject.zip index caae736b8..ff3e8a54e 100644 Binary files a/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/resources/org/apache/plc4x/malbec/projecttype/core/Plc4xProjectProject.zip and b/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/resources/org/apache/plc4x/malbec/projecttype/core/Plc4xProjectProject.zip differ diff --git a/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/resources/org/apache/plc4x/malbec/projecttype/impl/Bundle.properties b/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/resources/org/apache/plc4x/malbec/projecttype/impl/Bundle.properties index 23d9750e1..51e04edae 100644 --- a/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/resources/org/apache/plc4x/malbec/projecttype/impl/Bundle.properties +++ b/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/resources/org/apache/plc4x/malbec/projecttype/impl/Bundle.properties @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + #Localized module labels. Defaults taken from POM (, , ) if unset. #OpenIDE-Module-Name= #OpenIDE-Module-Short-Description= diff --git a/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/resources/org/apache/plc4x/malbec/projecttype/nodes/Bundle.properties b/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/resources/org/apache/plc4x/malbec/projecttype/nodes/Bundle.properties index 23d9750e1..51e04edae 100644 --- a/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/resources/org/apache/plc4x/malbec/projecttype/nodes/Bundle.properties +++ b/plc4j/tools/malbec/malbec-projecttype/malbec-projecttype/src/main/resources/org/apache/plc4x/malbec/projecttype/nodes/Bundle.properties @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + #Localized module labels. Defaults taken from POM (, , ) if unset. #OpenIDE-Module-Name= #OpenIDE-Module-Short-Description= diff --git a/plc4j/tools/malbec/malbec-projecttype/pics/pom.xml b/plc4j/tools/malbec/malbec-projecttype/pics/pom.xml index 505d4cdb2..f66452abe 100644 --- a/plc4j/tools/malbec/malbec-projecttype/pics/pom.xml +++ b/plc4j/tools/malbec/malbec-projecttype/pics/pom.xml @@ -1,13 +1,30 @@ + 4.0.0 org.apache.plc4x.malbec.projecttype projecttype - 0.13.0-SNAPSHOT + 1.1.0-SNAPSHOT - - org.apache.plc4x.malbec.projecttype + pics ${project.groupId}:${project.artifactId} nbm diff --git a/plc4j/tools/malbec/malbec-projecttype/pics/src/main/resources/org/apache/plc4x/malbec/pics/impl/Bundle.properties b/plc4j/tools/malbec/malbec-projecttype/pics/src/main/resources/org/apache/plc4x/malbec/pics/impl/Bundle.properties index 269d85d34..edd509598 100644 --- a/plc4j/tools/malbec/malbec-projecttype/pics/src/main/resources/org/apache/plc4x/malbec/pics/impl/Bundle.properties +++ b/plc4j/tools/malbec/malbec-projecttype/pics/src/main/resources/org/apache/plc4x/malbec/pics/impl/Bundle.properties @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + #Localized module labels. Defaults taken from POM (, , ) if unset. #OpenIDE-Module-Name= #OpenIDE-Module-Short-Description= diff --git a/plc4j/tools/malbec/malbec-projecttype/pom.xml b/plc4j/tools/malbec/malbec-projecttype/pom.xml index 43c3d8d5e..384c8f5da 100644 --- a/plc4j/tools/malbec/malbec-projecttype/pom.xml +++ b/plc4j/tools/malbec/malbec-projecttype/pom.xml @@ -1,10 +1,28 @@ + 4.0.0 org.apache.plc4x malbec-parent - 0.13.0-SNAPSHOT + 1.1.0-SNAPSHOT org.apache.plc4x.malbec.projecttype diff --git a/plc4j/tools/malbec/malbec-projecttype/recipes/pom.xml b/plc4j/tools/malbec/malbec-projecttype/recipes/pom.xml index d426e8602..e93917670 100644 --- a/plc4j/tools/malbec/malbec-projecttype/recipes/pom.xml +++ b/plc4j/tools/malbec/malbec-projecttype/recipes/pom.xml @@ -1,13 +1,30 @@ + 4.0.0 org.apache.plc4x.malbec.projecttype projecttype - 0.13.0-SNAPSHOT + 1.1.0-SNAPSHOT - - org.apache.plc4x.malbec.projecttype + recipes ${project.groupId}:${project.artifactId} nbm diff --git a/plc4j/tools/malbec/malbec-projecttype/recipes/src/main/java/org/apache/plc4x/malbec/recipes/impl/Plc4xRecipesSubProjectLogicalViewProviderImpl.java b/plc4j/tools/malbec/malbec-projecttype/recipes/src/main/java/org/apache/plc4x/malbec/recipes/impl/Plc4xRecipesSubProjectLogicalViewProviderImpl.java index 922e87fc1..91938b1e0 100644 --- a/plc4j/tools/malbec/malbec-projecttype/recipes/src/main/java/org/apache/plc4x/malbec/recipes/impl/Plc4xRecipesSubProjectLogicalViewProviderImpl.java +++ b/plc4j/tools/malbec/malbec-projecttype/recipes/src/main/java/org/apache/plc4x/malbec/recipes/impl/Plc4xRecipesSubProjectLogicalViewProviderImpl.java @@ -45,7 +45,7 @@ public class Plc4xRecipesSubProjectLogicalViewProviderImpl implements LogicalVi private final Project project; public Plc4xRecipesSubProjectLogicalViewProviderImpl(Project project) { - this.project = null; + this.project = project; } @Override diff --git a/plc4j/tools/malbec/malbec-projecttype/recipes/src/main/resources/org/apache/plc4x/malbec/recipes/nodes/Bundle.properties b/plc4j/tools/malbec/malbec-projecttype/recipes/src/main/resources/org/apache/plc4x/malbec/recipes/nodes/Bundle.properties index 382c6fabe..de4865547 100644 --- a/plc4j/tools/malbec/malbec-projecttype/recipes/src/main/resources/org/apache/plc4x/malbec/recipes/nodes/Bundle.properties +++ b/plc4j/tools/malbec/malbec-projecttype/recipes/src/main/resources/org/apache/plc4x/malbec/recipes/nodes/Bundle.properties @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + #Localized module labels. Defaults taken from POM (, , ) if unset. #OpenIDE-Module-Name= #OpenIDE-Module-Short-Description= diff --git a/plc4j/tools/malbec/malbec-projecttype/scripts/pom.xml b/plc4j/tools/malbec/malbec-projecttype/scripts/pom.xml index 90445a0f8..402932977 100644 --- a/plc4j/tools/malbec/malbec-projecttype/scripts/pom.xml +++ b/plc4j/tools/malbec/malbec-projecttype/scripts/pom.xml @@ -1,13 +1,30 @@ + 4.0.0 org.apache.plc4x.malbec.projecttype projecttype - 0.13.0-SNAPSHOT + 1.1.0-SNAPSHOT - - org.apache.plc4x.malbec.projecttype + scripts ${project.groupId}:${project.artifactId} nbm diff --git a/plc4j/tools/malbec/malbec-projecttype/scripts/src/main/resources/org/apache/plc4x/malbec/scripts/Bundle.properties b/plc4j/tools/malbec/malbec-projecttype/scripts/src/main/resources/org/apache/plc4x/malbec/scripts/Bundle.properties index 10d8fdad4..4734d66e2 100644 --- a/plc4j/tools/malbec/malbec-projecttype/scripts/src/main/resources/org/apache/plc4x/malbec/scripts/Bundle.properties +++ b/plc4j/tools/malbec/malbec-projecttype/scripts/src/main/resources/org/apache/plc4x/malbec/scripts/Bundle.properties @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + #Localized module labels. Defaults taken from POM (, , ) if unset. #OpenIDE-Module-Name= #OpenIDE-Module-Short-Description= diff --git a/plc4j/tools/malbec/malbec-projecttype/securities/pom.xml b/plc4j/tools/malbec/malbec-projecttype/securities/pom.xml index 83ad3d6f2..877ed05d4 100644 --- a/plc4j/tools/malbec/malbec-projecttype/securities/pom.xml +++ b/plc4j/tools/malbec/malbec-projecttype/securities/pom.xml @@ -1,13 +1,30 @@ + 4.0.0 org.apache.plc4x.malbec.projecttype projecttype - 0.13.0-SNAPSHOT + 1.1.0-SNAPSHOT - - org.apache.plc4x.malbec.projecttype + securities ${project.groupId}:${project.artifactId} nbm diff --git a/plc4j/tools/malbec/malbec-projecttype/securities/src/main/resources/org/apache/plc4x/malbec/securities/Bundle.properties b/plc4j/tools/malbec/malbec-projecttype/securities/src/main/resources/org/apache/plc4x/malbec/securities/Bundle.properties index c2e6c78d1..dda9f1823 100644 --- a/plc4j/tools/malbec/malbec-projecttype/securities/src/main/resources/org/apache/plc4x/malbec/securities/Bundle.properties +++ b/plc4j/tools/malbec/malbec-projecttype/securities/src/main/resources/org/apache/plc4x/malbec/securities/Bundle.properties @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + #Localized module labels. Defaults taken from POM (, , ) if unset. #OpenIDE-Module-Name= #OpenIDE-Module-Short-Description= diff --git a/plc4j/tools/malbec/malbec-projecttype/tgl/pom.xml b/plc4j/tools/malbec/malbec-projecttype/tgl/pom.xml index 0668855b1..d993bbf8b 100644 --- a/plc4j/tools/malbec/malbec-projecttype/tgl/pom.xml +++ b/plc4j/tools/malbec/malbec-projecttype/tgl/pom.xml @@ -1,13 +1,30 @@ + 4.0.0 org.apache.plc4x.malbec.projecttype projecttype - 0.13.0-SNAPSHOT + 1.1.0-SNAPSHOT - - org.apache.plc4x.malbec.projecttype + tgl ${project.groupId}:${project.artifactId} nbm diff --git a/plc4j/tools/malbec/malbec-projecttype/tgl/src/main/resources/org/apache/plc4x/malbec/tgl/Bundle.properties b/plc4j/tools/malbec/malbec-projecttype/tgl/src/main/resources/org/apache/plc4x/malbec/tgl/Bundle.properties index 6fa68f6e3..ef774f515 100644 --- a/plc4j/tools/malbec/malbec-projecttype/tgl/src/main/resources/org/apache/plc4x/malbec/tgl/Bundle.properties +++ b/plc4j/tools/malbec/malbec-projecttype/tgl/src/main/resources/org/apache/plc4x/malbec/tgl/Bundle.properties @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + #Localized module labels. Defaults taken from POM (, , ) if unset. #OpenIDE-Module-Name= #OpenIDE-Module-Short-Description= diff --git a/plc4j/tools/malbec/malbec-projecttype/udt/pom.xml b/plc4j/tools/malbec/malbec-projecttype/udt/pom.xml index c4d57bdb2..30625e831 100644 --- a/plc4j/tools/malbec/malbec-projecttype/udt/pom.xml +++ b/plc4j/tools/malbec/malbec-projecttype/udt/pom.xml @@ -1,13 +1,30 @@ + 4.0.0 org.apache.plc4x.malbec.projecttype projecttype - 0.13.0-SNAPSHOT + 1.1.0-SNAPSHOT - - org.apache.plc4x.malbec.projecttype + udt ${project.groupId}:${project.artifactId} nbm diff --git a/plc4j/tools/malbec/malbec-projecttype/udt/src/main/resources/org/apache/plc4x/malbec/udt/Bundle.properties b/plc4j/tools/malbec/malbec-projecttype/udt/src/main/resources/org/apache/plc4x/malbec/udt/Bundle.properties index a9e1f7629..b62a81cff 100644 --- a/plc4j/tools/malbec/malbec-projecttype/udt/src/main/resources/org/apache/plc4x/malbec/udt/Bundle.properties +++ b/plc4j/tools/malbec/malbec-projecttype/udt/src/main/resources/org/apache/plc4x/malbec/udt/Bundle.properties @@ -1,3 +1,20 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + #Localized module labels. Defaults taken from POM (, , ) if unset. #OpenIDE-Module-Name= #OpenIDE-Module-Short-Description= diff --git a/plc4j/tools/malbec/malbec-s88/api/pom.xml b/plc4j/tools/malbec/malbec-s88/api/pom.xml new file mode 100644 index 000000000..5715373a5 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/api/pom.xml @@ -0,0 +1,70 @@ + + + + 4.0.0 + + org.apache.plc4x.malbec.s88 + s88 + 1.1.0-SNAPSHOT + + + api + org.apache.plc4x.malbec.s88:api + nbm + + + + + org.apache.netbeans.utilities + nbm-maven-plugin + true + + + org.apache.plc4x.malbec.s88.api + + + + + org.apache.maven.plugins + maven-jar-plugin + + + ${project.build.outputDirectory}/META-INF/MANIFEST.MF + + + + + + + + + org.slf4j + slf4j-api + 1.7.36 + + + org.junit.jupiter + junit-jupiter-api + 5.10.1 + test + + + + diff --git a/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/model/ConfigurationOption.java b/plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/DataType.java similarity index 53% rename from plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/model/ConfigurationOption.java rename to plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/DataType.java index c8d5ef5f5..44c2e5fc9 100644 --- a/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/model/ConfigurationOption.java +++ b/plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/DataType.java @@ -17,36 +17,36 @@ * under the License. */ -package org.apache.plc4x.java.tools.ui.model; - -public class ConfigurationOption { - - public final String name; - public final String typeName; - public final boolean required; - public final Object defaultValue; - - public ConfigurationOption(String name, String typeName, boolean required, Object defaultValue) { - this.name = name; - this.typeName = typeName; - this.required = required; - this.defaultValue = defaultValue; +package org.apache.plc4x.malbec.s88.api; + +public enum DataType { + INTEGER, + REAL, + STRING, + ENUMERATION; + + public static DataType fromString(String text) { + if (text == null) return null; + for (DataType type : values()) { + if (type.name().equalsIgnoreCase(text.trim())) return type; + } + return null; } - public String getName() { - return name; + public static boolean isEnumeration(String text) { + return fromString(text) == ENUMERATION; } - public String getTypeName() { - return typeName; + public static String[] displayNames() { + return new String[]{ + INTEGER.name(), + REAL.name(), + STRING.name(), + ENUMERATION.name() + }; } - public boolean isRequired() { - return required; + public static DataType defaultForEmpty() { + return INTEGER; } - - public Object getDefaultValue() { - return defaultValue; - } - } diff --git a/plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/EngineeringUnits.java b/plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/EngineeringUnits.java new file mode 100644 index 000000000..bd630c14d --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/EngineeringUnits.java @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.plc4x.malbec.s88.api; + +public enum EngineeringUnits { + CELSIUS(Magnitude.TEMPERATURE, "C"), + FAHRENHEIT(Magnitude.TEMPERATURE, "F"), + KELVIN(Magnitude.TEMPERATURE, "K"), + + MILLISECOND(Magnitude.TIME, "MILLISECOND"), + SECOND(Magnitude.TIME, "SECOND"), + MINUTE(Magnitude.TIME, "MIN"), + HOUR(Magnitude.TIME, "HOUR"), + DAY(Magnitude.TIME, "DAY"), + WEEK(Magnitude.TIME, "WEEK"), + MONTH(Magnitude.TIME, "MONTH"), + YEAR(Magnitude.TIME, "YEAR"), + + PASCAL(Magnitude.PRESSURE, "PASCAL"), + PSI(Magnitude.PRESSURE, "PSI"), + MILLIBAR(Magnitude.PRESSURE, "MBAR"), + BAR(Magnitude.PRESSURE, "BAR"), + ATMOSPHERE(Magnitude.PRESSURE, "ATM"), + + MILLIGRAM(Magnitude.WEIGHT, "MG"), + KILOGRAM(Magnitude.WEIGHT, "KG"), + POUND(Magnitude.WEIGHT, "LB"), + + MILLISIEMENS(Magnitude.CONDUCTIVITY, "MILLISIEMENS"), + SIEMENS(Magnitude.CONDUCTIVITY, "SIEMENS"), + + MILLILITER(Magnitude.VOLUME, "ML"), + LITER(Magnitude.VOLUME, "L"), + HECTOLITER(Magnitude.VOLUME, "HL"), + CUBIC_CENTIMETER(Magnitude.VOLUME, "CC"), + CUBIC_METER(Magnitude.VOLUME, "M3"), + + MILLIMETER(Magnitude.LONGITUDE, "MM"), + CENTIMETER(Magnitude.LONGITUDE, "CM"), + METER(Magnitude.LONGITUDE, "M"), + INCHES(Magnitude.LONGITUDE, "IN"), + FEET(Magnitude.LONGITUDE, "FEET"), + + RPM(Magnitude.FREQUENCY, "RPM"), + + CUBIC_METER_H(Magnitude.FLOW, "M3H"), + + PERCENTAGE(Magnitude.NONE, "%"), + NONE(Magnitude.NONE, "") + + ; + private final Magnitude magnitude; + private final String name; + + EngineeringUnits(Magnitude m, String name) { + this.magnitude = m; + this.name = name; + } + + public Magnitude getMagnitude() { + return this.magnitude; + } + + public String getName() { + return this.name; + } + + public static EngineeringUnits fromName(String name) { + if (name == null) return NONE; + String trimmed = name.trim(); + for (EngineeringUnits e : values()) { + if (e.getName().equalsIgnoreCase(trimmed) || e.name().equalsIgnoreCase(trimmed)) return e; + } + return NONE; + } +} diff --git a/plc4j/tools/ui/frontend/project/src/utils/Counter.ts b/plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/Magnitude.java similarity index 76% rename from plc4j/tools/ui/frontend/project/src/utils/Counter.ts rename to plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/Magnitude.java index fb4e405d2..8e781fdf0 100644 --- a/plc4j/tools/ui/frontend/project/src/utils/Counter.ts +++ b/plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/Magnitude.java @@ -17,12 +17,21 @@ * under the License. */ -export class Counter { - - private _value:number = 0; - - public getAndIncrement():number { - return this._value++ - } +package org.apache.plc4x.malbec.s88.api; +public enum Magnitude { + TEMPERATURE, + PRESSURE, + TIME, + VOLUME, + CONDUCTIVITY, + VELOCITY, + FLOW, + POWER, + VOLTAGE, + CURRENT, + LONGITUDE, + FREQUENCY, + WEIGHT, + NONE } diff --git a/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/configuration/Plc4xConfiguration.java b/plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/S88ChangeEvent.java similarity index 68% rename from plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/configuration/Plc4xConfiguration.java rename to plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/S88ChangeEvent.java index 5d39e84fd..47b136f8f 100644 --- a/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/configuration/Plc4xConfiguration.java +++ b/plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/S88ChangeEvent.java @@ -16,19 +16,23 @@ * specific language governing permissions and limitations * under the License. */ +package org.apache.plc4x.malbec.s88.api; -package org.apache.plc4x.java.tools.ui.configuration; - -import org.apache.plc4x.java.api.PlcDriverManager; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; +/** + * Event representing a change in the S88 model. + */ +public record S88ChangeEvent(Type type, S88Element element, String propertyName) { -@Configuration -public class Plc4xConfiguration { + public enum Type { + ADDED, + REMOVED, + UPDATED, + MOVED, + RELOADED + } - @Bean - public PlcDriverManager driverManager() { - return PlcDriverManager.getDefault(); + public S88ChangeEvent(Type type, S88Element element) { + this(type, element, null); } } diff --git a/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/event/EventType.java b/plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/S88ChangeListener.java similarity index 82% rename from plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/event/EventType.java rename to plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/S88ChangeListener.java index 4e3e4b662..21721fcc5 100644 --- a/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/event/EventType.java +++ b/plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/S88ChangeListener.java @@ -16,13 +16,12 @@ * specific language governing permissions and limitations * under the License. */ +package org.apache.plc4x.malbec.s88.api; -package org.apache.plc4x.java.tools.ui.event; - -public enum EventType { - - CREATED, - UPDATED, - DELETED - +/** + * Listener for S88 model changes. + */ +public interface S88ChangeListener { + + void onS88Change(S88ChangeEvent event); } diff --git a/plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/S88Element.java b/plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/S88Element.java new file mode 100644 index 000000000..7b9a9b65a --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/S88Element.java @@ -0,0 +1,173 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.plc4x.malbec.s88.api; + +import java.util.*; + +/** + * AREA
+ * PROCESS CELL
+ * UNIT
+ * EQUIPMENT MODULE + */ +public class S88Element { + + + private String id; + private S88Level level; + private S88Element parent; + private final List children = new ArrayList<>(); + private final Map properties = new LinkedHashMap<>(); + private S88ElementClass elementClass; + private final List elementClasses = new ArrayList<>(); + private static final String CHECK = "Check"; + + + public S88Element setClass(S88ElementClass elementClass){ + this.elementClass = elementClass; + return this; + } + + public void setCheck(boolean check){ + setProperty(CHECK, check); + } + + public boolean isCheck(){ + Object value = getProperty(CHECK); + if (value instanceof Boolean b) { + return b; + } + if (value instanceof String s) { + return Boolean.parseBoolean(s.trim()); + } + return false; + } + + public S88ElementClass getElementClass(){ + return this.elementClass; + } + + public void addElementClass(S88ElementClass elementClass){ + this.elementClasses.add(elementClass); + } + + public List getElementClasses(){ + return this.elementClasses; + } + + public S88Element setId(String id) { + this.id = id; + return this; + } + + public S88Element setLevel(S88Level level) { + this.level = level; + return this; + } + + public S88Element setParent(S88Element parent) { + this.parent = parent; + return this; + } + + public void setProperty(String k, Object v){ + if(v == null){ + this.properties.remove(k); + } else { + this.properties.put(k, v); + } + } + + public String getId() { + return id; + } + + public S88Level getLevel() { + return level; + } + + public S88Element getParent() { + return parent; + } + + public List getChildren() { + return children; + } + + public void addChild(S88Element element){ + this.children.add(element); + element.setParent(this); + } + + public void removeChild(S88Element element){ + this.children.remove(element); + element.setParent(null); + } + + public String getTypeName() { + return getClass().getSimpleName(); + } + + public Map getProperties() { + return properties; + } + + /** + * Collects the nested (structured) properties held by the given map. + *

+ * The returned map and each nested container are immutable snapshots; + * modifications must be applied through {@link #setProperty(String, Object)}. + * + * @param properties the map to inspect, or {@code null} to inspect the element's own properties + * @return immutable view of the nested property maps + */ + public Map> getStructuredProperties(Map properties) { + Map> result = new LinkedHashMap<>(); + + if (properties == null) properties = this.getProperties(); + + for (var entry : properties.entrySet()) { + if (entry.getValue() instanceof Map nested) { + @SuppressWarnings("unchecked") + Map typed = (Map) nested; + result.put(entry.getKey(), Collections.unmodifiableMap(new LinkedHashMap<>(typed))); + } + } + + return Collections.unmodifiableMap(result); + } + + /** + * Returns the property value for the given key. + * + * @param k property key + * @return the stored value, or {@code ""} when the key is absent (unlike + * {@link #getProperties()} which exposes {@code null} for missing keys) + */ + public Object getProperty(String k){ + return this.properties.getOrDefault(k, ""); + } + + public Map getStructuredProperty(String k){ + return this.getStructuredProperties(null).get(k); + } + + +} \ No newline at end of file diff --git a/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/model/Transport.java b/plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/S88ElementClass.java similarity index 51% rename from plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/model/Transport.java rename to plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/S88ElementClass.java index faebe25b2..a9068d826 100644 --- a/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/model/Transport.java +++ b/plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/S88ElementClass.java @@ -17,33 +17,44 @@ * under the License. */ -package org.apache.plc4x.java.tools.ui.model; +package org.apache.plc4x.malbec.s88.api; +import java.util.LinkedHashMap; import java.util.Map; -public class Transport { +/** + * EquipmentClass in B2MML. Template for S88Element grouping. + */ - private final String code; - private final String name; +public class S88ElementClass { + private String name; + private S88Level targetLevel; + private final Map properties = new LinkedHashMap<>(); - private final Map options; + public S88ElementClass() {} - public Transport(String code, String name, Map options) { - this.code = code; + public void setName(String name){ this.name = name; - this.options = options; } - public String getCode() { - return code; + public String getName(){ + return this.name; + } + + public void setProperty(String key, Object value){ + this.properties.put(key, value); } - public String getName() { - return name; + public Object getProperty(String key){ + return this.properties.get(key); } - public Map getOptions() { - return options; + public Map getProperties(){ + return this.properties; } + public void setTargetLevel(S88Level targetLevel) { this.targetLevel = targetLevel; } + + public S88Level getTargetLevel() { return this.targetLevel; } + } diff --git a/plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/S88Enumeration.java b/plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/S88Enumeration.java new file mode 100644 index 000000000..7bf928865 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/S88Enumeration.java @@ -0,0 +1,67 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.plc4x.malbec.s88.api; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Global enumeration definition for the plant. + * A named set of label -> numeric value pairs. Enumerations are global: they can be + * referenced from any level of the equipment hierarchy. + */ +public class S88Enumeration { + private String name; + private final Map values = new LinkedHashMap<>(); + + public S88Enumeration() { + } + + public S88Enumeration(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public void setValue(String label, Integer index) { + if (label == null || label.isBlank()) { + return; + } + if (index == null) { + values.remove(label); + } else { + values.put(label, index); + } + } + + public Map getValues() { + return values; + } + + public Integer getIndex(String label) { + return values.get(label); + } +} diff --git a/plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/S88Level.java b/plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/S88Level.java new file mode 100644 index 000000000..322b7bba4 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/S88Level.java @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.malbec.s88.api; + +/** + * Enumeration of ISA-88 Hierarchy Levels. + */ +public enum S88Level { + /** + * A Site logic division grouped by production type. + */ + AREA("Area"), + + /** + * Zone with Units and Equipments that work together for batch production. + */ + PROCESSCELL("Process Cell"), + + /** + * Principal equipment that carries physical/chemical transformations. + */ + UNIT("Unit"), + + /** + * Mechanical components that carries auxiliary functions. + */ + EQUIPMENTMODULE("Equipment Module"), + + + NULL(""); + + private final String displayName; + + S88Level(String displayName) { + this.displayName = displayName; + } + + public String getDisplayName() { + return displayName; + } + + public boolean isEmpty() { + return this == NULL; + } + + public S88Level getChildLevel() { + return switch (this) { + case EQUIPMENTMODULE -> null; + case NULL -> AREA; + case AREA -> PROCESSCELL; + case PROCESSCELL -> UNIT; + case UNIT -> EQUIPMENTMODULE; + }; + } + + + /** + * Given a text, returns a valid S88Level that matches. + * + * @param txt input text + * @return S88Level + */ + public static S88Level fromTxt(String txt) { + if (txt == null || txt.trim().isEmpty()) { + return NULL; + } + for (S88Level level : S88Level.values()) { + if (level.name().equalsIgnoreCase(txt) || level.displayName.equalsIgnoreCase(txt)) { + return level; + } + } + return NULL; + } +} diff --git a/plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/S88PlantModel.java b/plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/S88PlantModel.java new file mode 100644 index 000000000..50aeff864 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/S88PlantModel.java @@ -0,0 +1,209 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.malbec.s88.api; + +import java.util.*; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * S88 Physical Model for a plant + */ +public class S88PlantModel { + + private final S88Element root; + private final List listeners = new CopyOnWriteArrayList<>(); + private final Map idMap = new HashMap<>(); + private final Map classes = new LinkedHashMap<>(); + + public S88PlantModel(S88Element root) { + this.root = root; + rebuildIndex(); + } + + private void rebuildIndex() { + idMap.clear(); + if (root != null) { + addToIndex(root); + } + } + + private void addToIndex(S88Element element) { + if (element.getId() != null) { + idMap.put(element.getId(), element); + } + for (S88Element child : element.getChildren()) { + addToIndex(child); + } + } + + + public S88Element getRoot() { + return root; + } + + public void registerClass(S88ElementClass ec) { + String name = ec != null ? ec.getName() : null; + if (name == null || name.trim().isEmpty()) { + throw new IllegalArgumentException("Template ID cannot be empty"); + } + if (classes.containsKey(name)) { + throw new IllegalStateException("Template with ID '" + name + "' already exists."); + } + classes.put(name, ec); + } + + public S88ElementClass findClass(String name) { return classes.get(name); } + + public Map getClasses(){ + return classes; + } + + public static final String ENUM_CLASS_PREFIX = "ENUM_"; + + public static boolean isEnumerationClass(S88ElementClass ec) { + return ec != null && ec.getName() != null && ec.getName().startsWith(ENUM_CLASS_PREFIX); + } + + public S88Enumeration toEnumeration(S88ElementClass ec) { + if (!isEnumerationClass(ec)) { + return null; + } + S88Enumeration enumeration = new S88Enumeration(ec.getName().substring(ENUM_CLASS_PREFIX.length())); + for (var entry : ec.getProperties().entrySet()) { + Object raw = entry.getValue(); + if (raw instanceof Map nested) { + Object idx = nested.get("index"); + Integer value = null; + if (idx instanceof Number n) { + value = n.intValue(); + } else if (idx != null) { + try { + value = Integer.parseInt(String.valueOf(idx).trim()); + } catch (NumberFormatException ignored) { + } + } + if (value != null) { + enumeration.setValue(entry.getKey(), value); + } + } + } + return enumeration; + } + + public S88ElementClass fromEnumeration(S88Enumeration enumeration) { + S88ElementClass ec = new S88ElementClass(); + ec.setName(ENUM_CLASS_PREFIX + enumeration.getName()); + for (var entry : enumeration.getValues().entrySet()) { + Map props = new LinkedHashMap<>(); + props.put("index", entry.getValue()); + ec.setProperty(entry.getKey(), props); + } + return ec; + } + + public void registerEnumeration(S88Enumeration enumeration) { + if (enumeration == null || enumeration.getName() == null || enumeration.getName().trim().isEmpty()) { + throw new IllegalArgumentException("Enumeration name cannot be empty"); + } + + String key = ENUM_CLASS_PREFIX + enumeration.getName(); + if (classes.containsKey(key)) { + throw new IllegalStateException("Template with ID '" + key + "' already exists."); + } + classes.put(key, fromEnumeration(enumeration)); + } + + public void unregisterEnumeration(String name) { + if (name == null) { + return; + } + classes.remove(ENUM_CLASS_PREFIX + name); + } + + public S88Enumeration findEnumeration(String name) { + S88ElementClass ec = classes.get(ENUM_CLASS_PREFIX + name); + return ec != null ? toEnumeration(ec) : null; + } + + public List getEnumerations() { + List result = new ArrayList<>(); + for (S88ElementClass ec : classes.values()) { + if (isEnumerationClass(ec)) { + result.add(toEnumeration(ec)); + } + } + return result; + } + + public List findInstancesOf(String className) { + List result = new ArrayList<>(); + if (root != null) { + collectInstances(root, className, result); + } + return result; + } + + private void collectInstances(S88Element element, String className, List result) { + S88ElementClass ec = element.getElementClass(); + if (ec != null && className.equals(ec.getName())) { + result.add(element); + } + for (S88Element child : element.getChildren()) { + collectInstances(child, className, result); + } + } + + + public Optional findById(String id) { + return Optional.ofNullable(idMap.get(id)); + } + + + public void addChangeListener(S88ChangeListener listener) { + listeners.add(listener); + } + + + public void removeChangeListener(S88ChangeListener listener) { + listeners.remove(listener); + } + + + public void fireChangeEvent(S88ChangeEvent event) { + if (null != event.type()) + switch (event.type()) { + case ADDED -> addToIndex(event.element()); + case REMOVED -> removeFromIndex(event.element()); + case RELOADED -> rebuildIndex(); + default -> { + } + } + + for (S88ChangeListener listener : listeners) { + listener.onS88Change(event); + } + } + + private void removeFromIndex(S88Element element) { + idMap.remove(element.getId()); + for (S88Element child : element.getChildren()) { + removeFromIndex(child); + } + } +} diff --git a/plc4j/tools/ui/application/src/test/java/org/apache/plc4x/java/tools/ui/Plc4xUiBackendApplicationTests.java b/plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/S88Repository.java similarity index 60% rename from plc4j/tools/ui/application/src/test/java/org/apache/plc4x/java/tools/ui/Plc4xUiBackendApplicationTests.java rename to plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/S88Repository.java index 879b9d833..8a5753a86 100644 --- a/plc4j/tools/ui/application/src/test/java/org/apache/plc4x/java/tools/ui/Plc4xUiBackendApplicationTests.java +++ b/plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/S88Repository.java @@ -7,7 +7,7 @@ * "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 + * 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 @@ -16,19 +16,24 @@ * specific language governing permissions and limitations * under the License. */ +package org.apache.plc4x.malbec.s88.api; -package org.apache.plc4x.java.tools.ui; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; - -@SpringBootTest -@ActiveProfiles(value = "test") -class Plc4xUiBackendApplicationTests { - - @Test - void contextLoads() { - } +/** + * + * @author Daniel + * Interface for load and save plant models. + * + */ +public interface S88Repository { + /** + * Loads from any source and maps to Malbec S88 in-memory model. + * @return model for malbec. + */ + S88PlantModel loadPlant(); + /** + * Exports from malbec model to any model/format required. + * @param model Malbec model + */ + void savePlant(S88PlantModel model); } diff --git a/plc4j/tools/ui/frontend/project/src/pages/Mqtt.tsx b/plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/S88RepositoryProvider.java similarity index 75% rename from plc4j/tools/ui/frontend/project/src/pages/Mqtt.tsx rename to plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/S88RepositoryProvider.java index a72ab04d7..22bd52a8e 100644 --- a/plc4j/tools/ui/frontend/project/src/pages/Mqtt.tsx +++ b/plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/S88RepositoryProvider.java @@ -7,7 +7,7 @@ * "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 + * 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 @@ -16,9 +16,13 @@ * specific language governing permissions and limitations * under the License. */ +package org.apache.plc4x.malbec.s88.api; -export default function Inspect() { - return ( -

MQTT

- ) +/** + * + * @author Daniel + */ +public interface S88RepositoryProvider { + boolean accepts(String format); + S88Repository createRepository(S88Storage storage); } diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/Plc4xReadResponseRowCallback.java b/plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/S88Storage.java similarity index 70% rename from plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/Plc4xReadResponseRowCallback.java rename to plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/S88Storage.java index 945beb75b..afa326f64 100644 --- a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/Plc4xReadResponseRowCallback.java +++ b/plc4j/tools/malbec/malbec-s88/api/src/main/java/org/apache/plc4x/malbec/s88/api/S88Storage.java @@ -7,7 +7,7 @@ * "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 + * 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 @@ -16,12 +16,17 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.plc4x.nifi.record; - -import org.apache.plc4x.java.api.messages.PlcReadResponse; +package org.apache.plc4x.malbec.s88.api; import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; -public interface Plc4xReadResponseRowCallback { - void processRow(PlcReadResponse result) throws IOException; +/** + *Helper for I/O operations + * @author Daniel + */ +public interface S88Storage { + InputStream openInput() throws IOException; + OutputStream openOutput() throws IOException; } diff --git a/plc4j/tools/malbec/malbec-s88/batch/pom.xml b/plc4j/tools/malbec/malbec-s88/batch/pom.xml new file mode 100644 index 000000000..842a21d56 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/batch/pom.xml @@ -0,0 +1,55 @@ + + + + 4.0.0 + + org.apache.plc4x.malbec.s88 + s88 + 1.1.0-SNAPSHOT + + batch + nbm + + + + org.apache.netbeans.utilities + nbm-maven-plugin + true + + + org.apache.maven.plugins + maven-jar-plugin + + + ${project.build.outputDirectory}/META-INF/MANIFEST.MF + + + + + + + + org.netbeans.api + org-netbeans-api-annotations-common + ${netbeans.version} + + + ${project.groupId}:${project.artifactId} + \ No newline at end of file diff --git a/plc4j/tools/malbec/malbec-s88/batch/src/main/nbm/manifest.mf b/plc4j/tools/malbec/malbec-s88/batch/src/main/nbm/manifest.mf new file mode 100644 index 000000000..e5a67fe06 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/batch/src/main/nbm/manifest.mf @@ -0,0 +1,3 @@ +Manifest-Version: 1.0 +OpenIDE-Module-Localizing-Bundle: org/apache/plc4x/malbec/s88/batch/Bundle.properties + diff --git a/plc4j/tools/ui/application/src/main/resources/application-dev.properties b/plc4j/tools/malbec/malbec-s88/batch/src/main/resources/org/apache/plc4x/malbec/s88/batch/Bundle.properties similarity index 71% rename from plc4j/tools/ui/application/src/main/resources/application-dev.properties rename to plc4j/tools/malbec/malbec-s88/batch/src/main/resources/org/apache/plc4x/malbec/s88/batch/Bundle.properties index fe95886d5..473e36410 100644 --- a/plc4j/tools/ui/application/src/main/resources/application-dev.properties +++ b/plc4j/tools/malbec/malbec-s88/batch/src/main/resources/org/apache/plc4x/malbec/s88/batch/Bundle.properties @@ -1,4 +1,3 @@ -# # Licensed to the Apache Software Foundation (ASF) under one # or more contributor license agreements. See the NOTICE file # distributed with this work for additional information @@ -7,7 +6,7 @@ # "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 +# 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 @@ -15,4 +14,10 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -# + +#Localized module labels. Defaults taken from POM (, , ) if unset. +#OpenIDE-Module-Name= +#OpenIDE-Module-Short-Description= +#OpenIDE-Module-Long-Description= +#OpenIDE-Module-Display-Category= +#Mon Sep 14 12:51:15 VET 2026 diff --git a/plc4j/tools/malbec/malbec-s88/core/pom.xml b/plc4j/tools/malbec/malbec-s88/core/pom.xml new file mode 100644 index 000000000..4509f6215 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/core/pom.xml @@ -0,0 +1,91 @@ + + + + 4.0.0 + + org.apache.plc4x.malbec.s88 + s88 + 1.1.0-SNAPSHOT + + core + nbm + + + + org.apache.netbeans.utilities + nbm-maven-plugin + true + + + org.apache.plc4x.malbec.s88.core + + + + + org.apache.maven.plugins + maven-jar-plugin + + + ${project.build.outputDirectory}/META-INF/MANIFEST.MF + + + + + + + + org.netbeans.api + org-netbeans-api-annotations-common + ${netbeans.version} + + + org.apache.plc4x.malbec.s88 + api + ${project.version} + + + + org.junit.jupiter + junit-jupiter-api + ${junit.jupiter.version} + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit.jupiter.version} + test + + + org.mockito + mockito-core + ${mockito.version} + test + jar + + + org.mockito + mockito-junit-jupiter + ${mockito.version} + test + + + ${project.groupId}:${project.artifactId} + \ No newline at end of file diff --git a/plc4j/tools/malbec/malbec-s88/core/src/main/java/org/apache/plc4x/malbec/s88/core/AddStructEntryUseCase.java b/plc4j/tools/malbec/malbec-s88/core/src/main/java/org/apache/plc4x/malbec/s88/core/AddStructEntryUseCase.java new file mode 100644 index 000000000..637454a9a --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/core/src/main/java/org/apache/plc4x/malbec/s88/core/AddStructEntryUseCase.java @@ -0,0 +1,54 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.plc4x.malbec.s88.core; + +import org.apache.plc4x.malbec.s88.api.S88ChangeEvent; +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.api.S88PlantModel; + +/** + * Use Case to add a new entry to a structured property of an S88 element. + *

+ * A {@code containerKey} of {@code null} adds a top level property (e.g. a Unit attribute), + * otherwise the entry is added to the nested map stored under {@code containerKey} + * (e.g. a parameter inside "Parameters"). + */ +public class AddStructEntryUseCase { + private AddStructEntryUseCase() { + /* This utility class should not be instantiated */ + } + + public static void execute(S88PlantModel model, S88Element element, String containerKey, + String entryKey, Object entryValue) { + if (element == null || entryKey == null || entryKey.trim().isEmpty()) { + throw new IllegalArgumentException("Entry name cannot be empty"); + } + if (entryValue == null) { + throw new IllegalArgumentException("Entry value cannot be null"); + } + if (StructEntrySupport.containsEntry(element, containerKey, entryKey)) { + throw new IllegalStateException("Entry '" + entryKey + "' already exists for this element."); + } + + StructEntrySupport.writeEntry(element, containerKey, entryKey, entryValue); + + model.fireChangeEvent(new S88ChangeEvent(S88ChangeEvent.Type.ADDED, element, + containerKey != null ? containerKey : entryKey)); + } +} diff --git a/plc4j/tools/malbec/malbec-s88/core/src/main/java/org/apache/plc4x/malbec/s88/core/CreateClassUseCase.java b/plc4j/tools/malbec/malbec-s88/core/src/main/java/org/apache/plc4x/malbec/s88/core/CreateClassUseCase.java new file mode 100644 index 000000000..e2ee55849 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/core/src/main/java/org/apache/plc4x/malbec/s88/core/CreateClassUseCase.java @@ -0,0 +1,73 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.plc4x.malbec.s88.core; + +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.api.S88ElementClass; +import org.apache.plc4x.malbec.s88.api.S88Level; +import org.apache.plc4x.malbec.s88.api.S88PlantModel; + +import java.util.Map; + +public class CreateClassUseCase { + private CreateClassUseCase() { + /* This utility class should not be instantiated */ + } + + public static void execute(S88PlantModel model, S88Element parent, String name, Map properties) { + if (name == null || name.trim().isEmpty()) { + throw new IllegalArgumentException("Name cannot be empty"); + } + + if (name.startsWith(S88PlantModel.ENUM_CLASS_PREFIX)) { + throw new IllegalArgumentException("'" + S88PlantModel.ENUM_CLASS_PREFIX + + "' is a reserved prefix for global enumerations."); + } + + if (model.getClasses().containsKey(name)) { + throw new IllegalStateException("Template with ID '" + name + "' already exists."); + } + + S88Element targetParent = parent != null ? parent : model.getRoot(); + + S88ElementClass elementClass = new S88ElementClass(); + elementClass.setName(name); + if (parent != null) { + S88Level childLevel = parent.getLevel() != null ? parent.getLevel().getChildLevel() : null; + if (childLevel == null) { + throw new IllegalStateException("Cannot create a template under '" + + (parent.getLevel() != null ? parent.getLevel() : "unknown") + + "' (leaf level)."); + } + elementClass.setTargetLevel(childLevel); + } + + + if (properties != null) { + for (String key : properties.keySet()) { + elementClass.setProperty(key, properties.get(key)); + } + } + + targetParent.addElementClass(elementClass); + model.registerClass(elementClass); + } + +} diff --git a/plc4j/tools/malbec/malbec-s88/core/src/main/java/org/apache/plc4x/malbec/s88/core/CreateElementUseCase.java b/plc4j/tools/malbec/malbec-s88/core/src/main/java/org/apache/plc4x/malbec/s88/core/CreateElementUseCase.java new file mode 100644 index 000000000..8f808e3f0 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/core/src/main/java/org/apache/plc4x/malbec/s88/core/CreateElementUseCase.java @@ -0,0 +1,67 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.plc4x.malbec.s88.core; + + +import org.apache.plc4x.malbec.s88.api.S88ChangeEvent; +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.api.S88ElementClass; +import org.apache.plc4x.malbec.s88.api.S88Level; +import org.apache.plc4x.malbec.s88.api.S88PlantModel; + +/** + * Use Case for creating a new S88 Element. + */ +public class CreateElementUseCase { + + public static void execute(S88PlantModel model, S88Element parent, String id, S88ElementClass s88ElementClass) { + if (id == null || id.trim().isEmpty()) { + throw new IllegalArgumentException("ID cannot be empty"); + } + + if (model.findById(id).isPresent()) { + throw new IllegalStateException("Element with ID '" + id + "' already exists."); + } + + S88Element targetParent = parent != null ? parent : model.getRoot(); + S88Level childLevel = targetParent.getLevel() != null ? targetParent.getLevel().getChildLevel() : null; + if (childLevel == null) { + throw new IllegalStateException("Cannot create an element under '" + + (targetParent.getLevel() != null ? targetParent.getLevel() : "unknown") + + "' (leaf level)."); + } + + S88Element child = + new S88Element() + .setId(id) + .setLevel(childLevel) + .setClass(s88ElementClass); + + + if(s88ElementClass!=null) { + for (var entry : s88ElementClass.getProperties().entrySet()) { + child.setProperty(entry.getKey(), entry.getValue()); + } + } + + + targetParent.addChild(child); + model.fireChangeEvent(new S88ChangeEvent(S88ChangeEvent.Type.ADDED, child)); + } +} diff --git a/plc4j/tools/malbec/malbec-s88/core/src/main/java/org/apache/plc4x/malbec/s88/core/DeleteElementUseCase.java b/plc4j/tools/malbec/malbec-s88/core/src/main/java/org/apache/plc4x/malbec/s88/core/DeleteElementUseCase.java new file mode 100644 index 000000000..c7d506cc1 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/core/src/main/java/org/apache/plc4x/malbec/s88/core/DeleteElementUseCase.java @@ -0,0 +1,45 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.plc4x.malbec.s88.core; + +import org.apache.plc4x.malbec.s88.api.S88ChangeEvent; +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.api.S88PlantModel; + +/** + * Use Case for deleting an S88 Element. + */ +public class DeleteElementUseCase { + private DeleteElementUseCase() { + /* This utility class should not be instantiated */ + } + + + public static void execute(S88PlantModel model, S88Element element) { + if (element == null || element == model.getRoot()) { + return; + } + + S88Element parent = element.getParent(); + if (parent != null) { + parent.removeChild(element); + model.fireChangeEvent(new S88ChangeEvent(S88ChangeEvent.Type.REMOVED, element)); + } + } +} diff --git a/plc4j/tools/malbec/malbec-s88/core/src/main/java/org/apache/plc4x/malbec/s88/core/RemoveStructEntryUseCase.java b/plc4j/tools/malbec/malbec-s88/core/src/main/java/org/apache/plc4x/malbec/s88/core/RemoveStructEntryUseCase.java new file mode 100644 index 000000000..b44800f8a --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/core/src/main/java/org/apache/plc4x/malbec/s88/core/RemoveStructEntryUseCase.java @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.plc4x.malbec.s88.core; + +import org.apache.plc4x.malbec.s88.api.S88ChangeEvent; +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.api.S88PlantModel; + +/** + * Use Case to remove an entry from a structured property of an S88 element. + *

+ * A {@code containerKey} of {@code null} removes a top level property (e.g. a Unit attribute), + * otherwise the entry is removed from the nested map stored under {@code containerKey}. + */ +public class RemoveStructEntryUseCase { + private RemoveStructEntryUseCase() { + /* This utility class should not be instantiated */ + } + + public static void execute(S88PlantModel model, S88Element element, String containerKey, String entryKey) { + if (element == null || entryKey == null || entryKey.trim().isEmpty()) { + throw new IllegalArgumentException("Entry name cannot be empty"); + } + if (!StructEntrySupport.containsEntry(element, containerKey, entryKey)) { + throw new IllegalStateException("Entry '" + entryKey + "' does not exist for this element."); + } + + StructEntrySupport.removeEntry(element, containerKey, entryKey); + + model.fireChangeEvent(new S88ChangeEvent(S88ChangeEvent.Type.REMOVED, element, + containerKey != null ? containerKey : entryKey)); + } +} diff --git a/plc4j/tools/malbec/malbec-s88/core/src/main/java/org/apache/plc4x/malbec/s88/core/RenameElementUseCase.java b/plc4j/tools/malbec/malbec-s88/core/src/main/java/org/apache/plc4x/malbec/s88/core/RenameElementUseCase.java new file mode 100644 index 000000000..cf946ef38 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/core/src/main/java/org/apache/plc4x/malbec/s88/core/RenameElementUseCase.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.plc4x.malbec.s88.core; + +import org.apache.plc4x.malbec.s88.api.S88ChangeEvent; +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.api.S88PlantModel; + +/** + * Use Case for renaming an S88 Element. + */ +public class RenameElementUseCase { + private RenameElementUseCase() { + /* This utility class should not be instantiated */ + } + + + public static void execute(S88PlantModel model, S88Element element, String newId) { + if (newId == null || newId.trim().isEmpty()) { + throw new IllegalArgumentException("ID cannot be empty"); + } + + if (model.findById(newId).isPresent()) { + throw new IllegalStateException("Element with ID '" + newId + "' already exists."); + } + + element.setId(newId); + + model.fireChangeEvent(new S88ChangeEvent(S88ChangeEvent.Type.RELOADED, element)); + } +} diff --git a/plc4j/tools/malbec/malbec-s88/core/src/main/java/org/apache/plc4x/malbec/s88/core/StructEntrySupport.java b/plc4j/tools/malbec/malbec-s88/core/src/main/java/org/apache/plc4x/malbec/s88/core/StructEntrySupport.java new file mode 100644 index 000000000..1a7f16f38 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/core/src/main/java/org/apache/plc4x/malbec/s88/core/StructEntrySupport.java @@ -0,0 +1,100 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.plc4x.malbec.s88.core; + +import org.apache.plc4x.malbec.s88.api.S88Element; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Internal helpers to read and write entries of a structured property. + *

+ * A {@code containerKey} of {@code null} addresses the element root (a top level property), + * while a non null {@code containerKey} addresses a nested map stored under that key + * (e.g. "Parameters" or "Reports"). + *

+ * Writes are copy-on-write: the container is cloned before being modified so the maps + * held by the element are never mutated in place. + */ +final class StructEntrySupport { + + private StructEntrySupport() { + /* This utility class should not be instantiated */ + } + + static Map copyContainer(S88Element element, String containerKey) { + Map copy = new LinkedHashMap<>(); + if (element == null || containerKey == null) { + return copy; + } + Object raw = element.getProperties().get(containerKey); + if (raw instanceof Map nested) { + for (Map.Entry entry : nested.entrySet()) { + copy.put(String.valueOf(entry.getKey()), entry.getValue()); + } + } + return copy; + } + + static boolean containsEntry(S88Element element, String containerKey, String entryKey) { + if (element == null || entryKey == null) { + return false; + } + if (containerKey == null) { + return element.getProperties().containsKey(entryKey); + } + Object raw = element.getProperties().get(containerKey); + return raw instanceof Map nested && nested.containsKey(entryKey); + } + + static Object getEntry(S88Element element, String containerKey, String entryKey) { + if (element == null || entryKey == null) { + return null; + } + if (containerKey == null) { + return element.getProperties().get(entryKey); + } + Object raw = element.getProperties().get(containerKey); + if (raw instanceof Map nested) { + return nested.get(entryKey); + } + return null; + } + + static void writeEntry(S88Element element, String containerKey, String entryKey, Object entryValue) { + if (containerKey == null) { + element.setProperty(entryKey, entryValue); + return; + } + Map container = copyContainer(element, containerKey); + container.put(entryKey, entryValue); + element.setProperty(containerKey, container); + } + + static void removeEntry(S88Element element, String containerKey, String entryKey) { + if (containerKey == null) { + element.setProperty(entryKey, null); + return; + } + Map container = copyContainer(element, containerKey); + container.remove(entryKey); + element.setProperty(containerKey, container); + } +} diff --git a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/Plc4xFullReadResponseRowCallback.java b/plc4j/tools/malbec/malbec-s88/core/src/main/java/org/apache/plc4x/malbec/s88/core/UpdatePropertyClassUseCase.java similarity index 66% rename from plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/Plc4xFullReadResponseRowCallback.java rename to plc4j/tools/malbec/malbec-s88/core/src/main/java/org/apache/plc4x/malbec/s88/core/UpdatePropertyClassUseCase.java index 27f6938b7..65272183f 100644 --- a/plc4j/integrations/apache-nifi/nifi-plc4x-processors/src/main/java/org/apache/plc4x/nifi/record/Plc4xFullReadResponseRowCallback.java +++ b/plc4j/tools/malbec/malbec-s88/core/src/main/java/org/apache/plc4x/malbec/s88/core/UpdatePropertyClassUseCase.java @@ -16,17 +16,18 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.plc4x.nifi.record; -import java.io.IOException; +package org.apache.plc4x.malbec.s88.core; -import org.apache.plc4x.java.api.messages.PlcReadResponse; +import org.apache.plc4x.malbec.s88.api.S88ElementClass; +import org.apache.plc4x.malbec.s88.api.S88PlantModel; -public class Plc4xFullReadResponseRowCallback implements Plc4xReadResponseRowCallback { +public class UpdatePropertyClassUseCase { + private UpdatePropertyClassUseCase() { + /* This utility class should not be instantiated */ + } - @Override - public void processRow(PlcReadResponse result) throws IOException { - // do nothing - } + public static void execute(S88PlantModel model, S88ElementClass elementClass, String key, Object value){ + } } diff --git a/plc4j/tools/malbec/malbec-s88/core/src/main/java/org/apache/plc4x/malbec/s88/core/UpdatePropertyUseCase.java b/plc4j/tools/malbec/malbec-s88/core/src/main/java/org/apache/plc4x/malbec/s88/core/UpdatePropertyUseCase.java new file mode 100644 index 000000000..f7f7e30f7 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/core/src/main/java/org/apache/plc4x/malbec/s88/core/UpdatePropertyUseCase.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.plc4x.malbec.s88.core; + +import org.apache.plc4x.malbec.s88.api.S88ChangeEvent; +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.api.S88PlantModel; + +import java.util.Objects; + +/** + * Use Case to update a property of an S88 element. + */ +public class UpdatePropertyUseCase { + private UpdatePropertyUseCase() { + /* This utility class should not be instantiated */ + } + + + public static void execute(S88PlantModel model, S88Element element, String key, Object value) { + if (element == null || key == null || key.isEmpty()) return; + + Object oldValue = element.getProperties().get(key); + if (Objects.equals(value, oldValue)) { + return; + } + + element.setProperty(key, value); + + model.fireChangeEvent(new S88ChangeEvent(S88ChangeEvent.Type.UPDATED, element, key)); + } +} diff --git a/plc4j/tools/malbec/malbec-s88/core/src/main/java/org/apache/plc4x/malbec/s88/core/UpdateStructEntryUseCase.java b/plc4j/tools/malbec/malbec-s88/core/src/main/java/org/apache/plc4x/malbec/s88/core/UpdateStructEntryUseCase.java new file mode 100644 index 000000000..6d6cdb6df --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/core/src/main/java/org/apache/plc4x/malbec/s88/core/UpdateStructEntryUseCase.java @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.plc4x.malbec.s88.core; + +import org.apache.plc4x.malbec.s88.api.S88ChangeEvent; +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.api.S88PlantModel; + +import java.util.Objects; + +/** + * Use Case to update an existing entry of a structured property of an S88 element. + *

+ * A {@code containerKey} of {@code null} updates a top level property (e.g. a Unit attribute), + * otherwise the entry is updated inside the nested map stored under {@code containerKey}. + */ +public class UpdateStructEntryUseCase { + private UpdateStructEntryUseCase() { + /* This utility class should not be instantiated */ + } + + public static void execute(S88PlantModel model, S88Element element, String containerKey, + String entryKey, Object entryValue) { + if (element == null || entryKey == null || entryKey.trim().isEmpty()) { + throw new IllegalArgumentException("Entry name cannot be empty"); + } + if (entryValue == null) { + throw new IllegalArgumentException("Entry value cannot be null"); + } + if (!StructEntrySupport.containsEntry(element, containerKey, entryKey)) { + throw new IllegalStateException("Entry '" + entryKey + "' does not exist for this element."); + } + + Object oldValue = StructEntrySupport.getEntry(element, containerKey, entryKey); + if (Objects.equals(oldValue, entryValue)) { + return; + } + + StructEntrySupport.writeEntry(element, containerKey, entryKey, entryValue); + + model.fireChangeEvent(new S88ChangeEvent(S88ChangeEvent.Type.UPDATED, element, + containerKey != null ? containerKey : entryKey)); + } +} diff --git a/plc4j/tools/malbec/malbec-s88/core/src/main/nbm/manifest.mf b/plc4j/tools/malbec/malbec-s88/core/src/main/nbm/manifest.mf new file mode 100644 index 000000000..2b35a8057 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/core/src/main/nbm/manifest.mf @@ -0,0 +1,3 @@ +Manifest-Version: 1.0 +OpenIDE-Module-Localizing-Bundle: org/apache/plc4x/malbec/s88/core/Bundle.properties + diff --git a/plc4j/tools/ui/application/src/main/resources/application.properties b/plc4j/tools/malbec/malbec-s88/core/src/main/resources/org/apache/plc4x/malbec/s88/core/Bundle.properties similarity index 57% rename from plc4j/tools/ui/application/src/main/resources/application.properties rename to plc4j/tools/malbec/malbec-s88/core/src/main/resources/org/apache/plc4x/malbec/s88/core/Bundle.properties index 0497f630c..aabba88f7 100644 --- a/plc4j/tools/ui/application/src/main/resources/application.properties +++ b/plc4j/tools/malbec/malbec-s88/core/src/main/resources/org/apache/plc4x/malbec/s88/core/Bundle.properties @@ -1,4 +1,3 @@ -# # Licensed to the Apache Software Foundation (ASF) under one # or more contributor license agreements. See the NOTICE file # distributed with this work for additional information @@ -7,7 +6,7 @@ # "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 +# 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 @@ -15,17 +14,10 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -# - -# Settings for the Derby sql database, which contains the application settings. -spring.jpa.generate-ddl=true -spring.jpa.hibernate.ddl-auto=update -spring.jpa.database=default -spring.datasource.url=jdbc:derby:directory:storage/settings;create=true - -# DATASOURCE (DataSourceAutoConfiguration & DataSourceProperties) -spring.sql.init.continue-on-error=false -spring.datasource.generate-unique-name=false -# Just setting this to the default in order to avoid warning messages. -spring.jpa.open-in-view=true +#Localized module labels. Defaults taken from POM (, , ) if unset. +#OpenIDE-Module-Name= +#OpenIDE-Module-Short-Description= +#OpenIDE-Module-Long-Description= +#OpenIDE-Module-Display-Category= +#Wed May 27 12:18:24 VET 2026 diff --git a/plc4j/tools/malbec/malbec-s88/core/src/test/java/org/apache/plc4x/malbec/s88/core/AddStructEntryUseCaseTest.java b/plc4j/tools/malbec/malbec-s88/core/src/test/java/org/apache/plc4x/malbec/s88/core/AddStructEntryUseCaseTest.java new file mode 100644 index 000000000..1dcf5e687 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/core/src/test/java/org/apache/plc4x/malbec/s88/core/AddStructEntryUseCaseTest.java @@ -0,0 +1,122 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.plc4x.malbec.s88.core; + +import org.apache.plc4x.malbec.s88.api.S88ChangeEvent; +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.api.S88Level; +import org.apache.plc4x.malbec.s88.api.S88PlantModel; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class AddStructEntryUseCaseTest { + + private S88PlantModel model; + private S88Element element; + + @BeforeEach + void setUp() { + model = mock(S88PlantModel.class); + element = new S88Element() + .setId("Unit1") + .setLevel(S88Level.UNIT); + } + + @Test + void testAddsRootLevelEntryAndFiresAddedEvent() { + AddStructEntryUseCase.execute(model, element, null, "icon", "x.png"); + + assertEquals("x.png", element.getProperty("icon")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(S88ChangeEvent.class); + verify(model).fireChangeEvent(captor.capture()); + assertEquals(S88ChangeEvent.Type.ADDED, captor.getValue().type()); + assertEquals("icon", captor.getValue().propertyName()); + assertSame(element, captor.getValue().element()); + } + + @Test + void testAddsNestedEntryAndCreatesContainer() { + AddStructEntryUseCase.execute(model, element, "Parameters", "P1", "v1"); + + Map parameters = element.getStructuredProperty("Parameters"); + assertNotNull(parameters); + assertEquals("v1", parameters.get("P1")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(S88ChangeEvent.class); + verify(model).fireChangeEvent(captor.capture()); + assertEquals(S88ChangeEvent.Type.ADDED, captor.getValue().type()); + assertEquals("Parameters", captor.getValue().propertyName()); + } + + @Test + void testAddingSecondNestedEntryIsCopyOnWrite() { + AddStructEntryUseCase.execute(model, element, "Parameters", "P1", "v1"); + Map previouslyReadContainer = element.getStructuredProperty("Parameters"); + + AddStructEntryUseCase.execute(model, element, "Parameters", "P2", "v2"); + Map currentContainer = element.getStructuredProperty("Parameters"); + + assertTrue(currentContainer.containsKey("P1")); + assertTrue(currentContainer.containsKey("P2")); + assertFalse(previouslyReadContainer.containsKey("P2"), + "Previously captured container must not be mutated in place"); + } + + @Test + void testThrowsOnDuplicateRootEntry() { + element.setProperty("icon", "x.png"); + + assertThrows(IllegalStateException.class, + () -> AddStructEntryUseCase.execute(model, element, null, "icon", "y.png")); + verify(model, never()).fireChangeEvent(any()); + } + + @Test + void testThrowsOnDuplicateNestedEntry() { + AddStructEntryUseCase.execute(model, element, "Parameters", "P1", "v1"); + + assertThrows(IllegalStateException.class, + () -> AddStructEntryUseCase.execute(model, element, "Parameters", "P1", "v2")); + } + + @Test + void testThrowsOnEmptyEntryKey() { + assertThrows(IllegalArgumentException.class, + () -> AddStructEntryUseCase.execute(model, element, null, " ", "v")); + } + + @Test + void testThrowsOnNullEntryValue() { + assertThrows(IllegalArgumentException.class, + () -> AddStructEntryUseCase.execute(model, element, null, "key", null)); + } + + @Test + void testThrowsOnNullElement() { + assertThrows(IllegalArgumentException.class, + () -> AddStructEntryUseCase.execute(model, null, null, "key", "v")); + } +} diff --git a/plc4j/tools/malbec/malbec-s88/core/src/test/java/org/apache/plc4x/malbec/s88/core/CreateClassUseCaseTest.java b/plc4j/tools/malbec/malbec-s88/core/src/test/java/org/apache/plc4x/malbec/s88/core/CreateClassUseCaseTest.java new file mode 100644 index 000000000..02a90093c --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/core/src/test/java/org/apache/plc4x/malbec/s88/core/CreateClassUseCaseTest.java @@ -0,0 +1,118 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.plc4x.malbec.s88.core; + +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.api.S88ElementClass; +import org.apache.plc4x.malbec.s88.api.S88Level; +import org.apache.plc4x.malbec.s88.api.S88PlantModel; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class CreateClassUseCaseTest { + + private S88PlantModel model; + private S88Element parent; + private S88Element root; + + @BeforeEach + void setUp() { + model = mock(S88PlantModel.class); + parent = new S88Element() + .setId("Unit1") + .setLevel(S88Level.UNIT); + root = new S88Element() + .setId("Root") + .setLevel(S88Level.AREA); + + when(model.getClasses()).thenReturn(new LinkedHashMap<>()); + when(model.getRoot()).thenReturn(root); + } + + @Test + void testCreatesClassAttachesToParentAndRegisters() { + Map properties = new LinkedHashMap<>(); + properties.put("Speed", "REAL"); + + CreateClassUseCase.execute(model, parent, "MotorClass", properties); + + ArgumentCaptor captor = ArgumentCaptor.forClass(S88ElementClass.class); + verify(model).registerClass(captor.capture()); + + S88ElementClass created = captor.getValue(); + assertEquals("MotorClass", created.getName()); + assertEquals(S88Level.EQUIPMENTMODULE, created.getTargetLevel()); + assertEquals("REAL", created.getProperty("Speed")); + assertTrue(parent.getElementClasses().contains(created)); + } + + @Test + void testUsesRootAndNoTargetLevelWhenParentIsNull() { + CreateClassUseCase.execute(model, null, "RootClass", null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(S88ElementClass.class); + verify(model).registerClass(captor.capture()); + + S88ElementClass created = captor.getValue(); + assertEquals("RootClass", created.getName()); + assertNull(created.getTargetLevel()); + assertTrue(root.getElementClasses().contains(created)); + } + + @Test + void testThrowsOnEmptyName() { + assertThrows(IllegalArgumentException.class, + () -> CreateClassUseCase.execute(model, parent, " ", null)); + } + + @Test + void testThrowsOnReservedPrefix() { + assertThrows(IllegalArgumentException.class, + () -> CreateClassUseCase.execute(model, parent, S88PlantModel.ENUM_CLASS_PREFIX + "Color", null)); + } + + @Test + void testThrowsOnDuplicateName() { + Map classes = new LinkedHashMap<>(); + classes.put("Duplicate", new S88ElementClass()); + when(model.getClasses()).thenReturn(classes); + + assertThrows(IllegalStateException.class, + () -> CreateClassUseCase.execute(model, parent, "Duplicate", null)); + verify(model, never()).registerClass(any()); + } + + @Test + void testThrowsWhenParentIsLeaf() { + S88Element em = new S88Element() + .setId("EM1") + .setLevel(S88Level.EQUIPMENTMODULE); + + assertThrows(IllegalStateException.class, + () -> CreateClassUseCase.execute(model, em, "LeafClass", null)); + verify(model, never()).registerClass(any()); + } +} diff --git a/plc4j/tools/malbec/malbec-s88/core/src/test/java/org/apache/plc4x/malbec/s88/core/CreateElementUseCaseTest.java b/plc4j/tools/malbec/malbec-s88/core/src/test/java/org/apache/plc4x/malbec/s88/core/CreateElementUseCaseTest.java new file mode 100644 index 000000000..8e79049c0 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/core/src/test/java/org/apache/plc4x/malbec/s88/core/CreateElementUseCaseTest.java @@ -0,0 +1,98 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.plc4x.malbec.s88.core; + +import java.util.Optional; +import org.apache.plc4x.malbec.s88.api.S88ChangeEvent; +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.api.S88Level; +import org.apache.plc4x.malbec.s88.api.S88PlantModel; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +public class CreateElementUseCaseTest { + + private CreateElementUseCase useCase; + private S88PlantModel model; + private S88Element root; + + @BeforeEach + void setUp() { + useCase = new CreateElementUseCase(); + model = mock(S88PlantModel.class); + root = new S88Element() + .setId("RootArea") + .setLevel(S88Level.AREA); + + when(model.getRoot()).thenReturn(root); + when(model.findById(anyString())).thenReturn(Optional.empty()); + } + + @Test + void testExecuteCreatesChildWithCorrectLevel() { + useCase.execute(model, root, "NewPC", null); + + assertEquals(1, root.getChildren().size()); + S88Element child = root.getChildren().get(0); + assertEquals("NewPC", child.getId()); + assertEquals(S88Level.PROCESSCELL, child.getLevel()); + } + + @Test + void testExecuteFiresEvent() { + useCase.execute(model, root, "NewPC", null); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(S88ChangeEvent.class); + verify(model).fireChangeEvent(eventCaptor.capture()); + + S88ChangeEvent event = eventCaptor.getValue(); + assertEquals(S88ChangeEvent.Type.ADDED, event.type()); + assertEquals("NewPC", event.element().getId()); + } + + @Test + void testExecuteThrowsOnDuplicateId() { + when(model.findById("Existing")).thenReturn(Optional.of(mock(S88Element.class))); + + assertThrows(IllegalStateException.class, () -> { + useCase.execute(model, root, "Existing", null); + }); + } + + @Test + void testExecuteThrowsOnEmptyId() { + assertThrows(IllegalArgumentException.class, () -> { + useCase.execute(model, root, "", null); + }); + } + + @Test + void testExecuteThrowsOnLeafParent() { + S88Element em = new S88Element() + .setId("EM1") + .setLevel(S88Level.EQUIPMENTMODULE); + + assertThrows(IllegalStateException.class, + () -> useCase.execute(model, em, "Child", null)); + } +} diff --git a/plc4j/tools/malbec/malbec-s88/core/src/test/java/org/apache/plc4x/malbec/s88/core/DeleteElementUseCaseTest.java b/plc4j/tools/malbec/malbec-s88/core/src/test/java/org/apache/plc4x/malbec/s88/core/DeleteElementUseCaseTest.java new file mode 100644 index 000000000..c496d9589 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/core/src/test/java/org/apache/plc4x/malbec/s88/core/DeleteElementUseCaseTest.java @@ -0,0 +1,62 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.plc4x.malbec.s88.core; + +import org.apache.plc4x.malbec.s88.api.S88ChangeEvent; +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.api.S88PlantModel; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class DeleteElementUseCaseTest { + + private S88PlantModel model; + private S88Element root; + + @BeforeEach + void setUp() { + model = mock(S88PlantModel.class); + root = new S88Element(); + root.setId("Root"); + when(model.getRoot()).thenReturn(root); + } + + @Test + void testExecuteRemovesChild() { + S88Element child = new S88Element(); + child.setId("Child"); + root.addChild(child); + + assertEquals(1, root.getChildren().size()); + + DeleteElementUseCase.execute(model, child); + + assertEquals(0, root.getChildren().size()); + verify(model).fireChangeEvent(any(S88ChangeEvent.class)); + } + + @Test + void testExecuteDoesNotDeleteRoot() { + DeleteElementUseCase.execute(model, root); + verify(model, never()).fireChangeEvent(any(S88ChangeEvent.class)); + } +} diff --git a/plc4j/tools/malbec/malbec-s88/core/src/test/java/org/apache/plc4x/malbec/s88/core/RemoveStructEntryUseCaseTest.java b/plc4j/tools/malbec/malbec-s88/core/src/test/java/org/apache/plc4x/malbec/s88/core/RemoveStructEntryUseCaseTest.java new file mode 100644 index 000000000..6113ac43d --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/core/src/test/java/org/apache/plc4x/malbec/s88/core/RemoveStructEntryUseCaseTest.java @@ -0,0 +1,115 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.plc4x.malbec.s88.core; + +import org.apache.plc4x.malbec.s88.api.S88ChangeEvent; +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.api.S88Level; +import org.apache.plc4x.malbec.s88.api.S88PlantModel; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class RemoveStructEntryUseCaseTest { + + private S88PlantModel model; + private S88Element element; + + @BeforeEach + void setUp() { + model = mock(S88PlantModel.class); + element = new S88Element() + .setId("Unit1") + .setLevel(S88Level.UNIT); + } + + @Test + void testRemovesRootLevelEntryAndFiresRemovedEvent() { + element.setProperty("icon", "x.png"); + + RemoveStructEntryUseCase.execute(model, element, null, "icon"); + + assertFalse(element.getProperties().containsKey("icon")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(S88ChangeEvent.class); + verify(model).fireChangeEvent(captor.capture()); + assertEquals(S88ChangeEvent.Type.REMOVED, captor.getValue().type()); + assertEquals("icon", captor.getValue().propertyName()); + assertSame(element, captor.getValue().element()); + } + + @Test + void testRemovesNestedEntryAndKeepsContainer() { + Map parameters = new LinkedHashMap<>(); + parameters.put("P1", "v1"); + parameters.put("P2", "v2"); + element.setProperty("Parameters", parameters); + + RemoveStructEntryUseCase.execute(model, element, "Parameters", "P1"); + + Map remaining = element.getStructuredProperty("Parameters"); + assertNotNull(remaining); + assertFalse(remaining.containsKey("P1")); + assertTrue(remaining.containsKey("P2")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(S88ChangeEvent.class); + verify(model).fireChangeEvent(captor.capture()); + assertEquals(S88ChangeEvent.Type.REMOVED, captor.getValue().type()); + assertEquals("Parameters", captor.getValue().propertyName()); + } + + @Test + void testNestedRemovalIsCopyOnWrite() { + Map parameters = new LinkedHashMap<>(); + parameters.put("P1", "v1"); + element.setProperty("Parameters", parameters); + Map previouslyReadContainer = element.getStructuredProperty("Parameters"); + + RemoveStructEntryUseCase.execute(model, element, "Parameters", "P1"); + + assertTrue(previouslyReadContainer.containsKey("P1"), + "Previously captured container must not be mutated in place"); + assertFalse(element.getStructuredProperty("Parameters").containsKey("P1")); + } + + @Test + void testThrowsWhenRootEntryMissing() { + assertThrows(IllegalStateException.class, + () -> RemoveStructEntryUseCase.execute(model, element, null, "missing")); + verify(model, never()).fireChangeEvent(any()); + } + + @Test + void testThrowsWhenNestedEntryMissing() { + assertThrows(IllegalStateException.class, + () -> RemoveStructEntryUseCase.execute(model, element, "Parameters", "P1")); + } + + @Test + void testThrowsOnEmptyEntryKey() { + assertThrows(IllegalArgumentException.class, + () -> RemoveStructEntryUseCase.execute(model, element, null, " ")); + } +} diff --git a/plc4j/tools/malbec/malbec-s88/core/src/test/java/org/apache/plc4x/malbec/s88/core/RenameElementUseCaseTest.java b/plc4j/tools/malbec/malbec-s88/core/src/test/java/org/apache/plc4x/malbec/s88/core/RenameElementUseCaseTest.java new file mode 100644 index 000000000..ae1e4fca2 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/core/src/test/java/org/apache/plc4x/malbec/s88/core/RenameElementUseCaseTest.java @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.plc4x.malbec.s88.core; + +import org.apache.plc4x.malbec.s88.api.S88ChangeEvent; +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.api.S88Level; +import org.apache.plc4x.malbec.s88.api.S88PlantModel; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class RenameElementUseCaseTest { + + private S88PlantModel model; + private S88Element element; + + @BeforeEach + void setUp() { + model = mock(S88PlantModel.class); + element = new S88Element() + .setId("OldName") + .setLevel(S88Level.UNIT); + when(model.findById(anyString())).thenReturn(Optional.empty()); + } + + @Test + void testRenamesElementAndFiresReloadedEvent() { + RenameElementUseCase.execute(model, element, "NewName"); + + assertEquals("NewName", element.getId()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(S88ChangeEvent.class); + verify(model).fireChangeEvent(captor.capture()); + assertEquals(S88ChangeEvent.Type.RELOADED, captor.getValue().type()); + assertSame(element, captor.getValue().element()); + } + + @Test + void testThrowsOnEmptyId() { + assertThrows(IllegalArgumentException.class, + () -> RenameElementUseCase.execute(model, element, " ")); + verify(model, never()).fireChangeEvent(any()); + } + + @Test + void testThrowsOnNullId() { + assertThrows(IllegalArgumentException.class, + () -> RenameElementUseCase.execute(model, element, null)); + } + + @Test + void testThrowsOnDuplicateId() { + when(model.findById("Taken")).thenReturn(Optional.of(mock(S88Element.class))); + + assertThrows(IllegalStateException.class, + () -> RenameElementUseCase.execute(model, element, "Taken")); + verify(model, never()).fireChangeEvent(any()); + } +} diff --git a/plc4j/tools/malbec/malbec-s88/core/src/test/java/org/apache/plc4x/malbec/s88/core/S88PlantModelTest.java b/plc4j/tools/malbec/malbec-s88/core/src/test/java/org/apache/plc4x/malbec/s88/core/S88PlantModelTest.java new file mode 100644 index 000000000..9725c7a20 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/core/src/test/java/org/apache/plc4x/malbec/s88/core/S88PlantModelTest.java @@ -0,0 +1,92 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.plc4x.malbec.s88.core; + +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.api.S88ElementClass; +import org.apache.plc4x.malbec.s88.api.S88PlantModel; +import org.junit.jupiter.api.Test; +import org.apache.plc4x.malbec.s88.api.S88Enumeration; + +import static org.junit.jupiter.api.Assertions.*; + +class S88PlantModelTest { + + @Test + void registerClassAllowsNewNames() { + S88PlantModel model = new S88PlantModel(new S88Element()); + S88ElementClass ec = new S88ElementClass(); + ec.setName("MotorClass"); + + model.registerClass(ec); + + assertSame(ec, model.findClass("MotorClass")); + } + + @Test + void registerClassThrowsOnDuplicateName() { + S88PlantModel model = new S88PlantModel(new S88Element()); + S88ElementClass first = new S88ElementClass(); + first.setName("MotorClass"); + model.registerClass(first); + + S88ElementClass second = new S88ElementClass(); + second.setName("MotorClass"); + + assertThrows(IllegalStateException.class, () -> model.registerClass(second)); + } + + @Test + void registerClassThrowsOnNullOrEmptyName() { + S88PlantModel model = new S88PlantModel(new S88Element()); + S88ElementClass ec = new S88ElementClass(); + + assertThrows(IllegalArgumentException.class, () -> model.registerClass(ec)); + assertThrows(IllegalArgumentException.class, () -> model.registerClass(null)); + } + + @Test + void registerEnumerationAllowsNewNames() { + S88PlantModel model = new S88PlantModel(new S88Element()); + S88Enumeration e = new S88Enumeration("EnumA"); + e.setValue("X", 0); + e.setValue("Y", 1); + + model.registerEnumeration(e); + + assertNotNull(model.findEnumeration("EnumA")); + } + + @Test + void registerEnumerationThrowsOnDuplicateName() { + S88PlantModel model = new S88PlantModel(new S88Element()); + model.registerEnumeration(new S88Enumeration("EnumA")); + + assertThrows(IllegalStateException.class, + () -> model.registerEnumeration(new S88Enumeration("EnumA"))); + } + + @Test + void registerEnumerationThrowsOnNullOrEmptyName() { + S88PlantModel model = new S88PlantModel(new S88Element()); + + assertThrows(IllegalArgumentException.class, () -> model.registerEnumeration(null)); + assertThrows(IllegalArgumentException.class, () -> model.registerEnumeration(new S88Enumeration(""))); + } +} \ No newline at end of file diff --git a/plc4j/tools/malbec/malbec-s88/core/src/test/java/org/apache/plc4x/malbec/s88/core/StructEntrySupportTest.java b/plc4j/tools/malbec/malbec-s88/core/src/test/java/org/apache/plc4x/malbec/s88/core/StructEntrySupportTest.java new file mode 100644 index 000000000..811b3c956 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/core/src/test/java/org/apache/plc4x/malbec/s88/core/StructEntrySupportTest.java @@ -0,0 +1,136 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.plc4x.malbec.s88.core; + +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class StructEntrySupportTest { + + private S88Element element; + + @BeforeEach + void setUp() { + element = new S88Element().setId("Element"); + } + + @Test + void copyContainerReturnsEmptyMapWhenElementIsNull() { + Map copy = StructEntrySupport.copyContainer(null, "Parameters"); + assertNotNull(copy); + assertTrue(copy.isEmpty()); + } + + @Test + void copyContainerReturnsEmptyMapWhenContainerKeyIsNull() { + assertTrue(StructEntrySupport.copyContainer(element, null).isEmpty()); + } + + @Test + void copyContainerReturnsEmptyMapWhenValueIsNotAMap() { + element.setProperty("x", "text"); + assertTrue(StructEntrySupport.copyContainer(element, "x").isEmpty()); + } + + @Test + void isCheckToleratesAbsentAndNonBooleanValues() { + assertFalse(element.isCheck()); + + element.setProperty("Check", "true"); + assertTrue(element.isCheck()); + + element.setProperty("Check", "garbage"); + assertFalse(element.isCheck()); + + element.setCheck(true); + assertTrue(element.isCheck()); + } + + @Test + void getStructuredPropertyReturnsImmutableSnapshot() { + Map nested = new LinkedHashMap<>(); + nested.put("P1", "v1"); + element.setProperty("Parameters", nested); + + Map view = element.getStructuredProperty("Parameters"); + assertNotNull(view); + assertThrows(UnsupportedOperationException.class, () -> view.put("P2", "v2")); + + Map> all = element.getStructuredProperties(null); + assertThrows(UnsupportedOperationException.class, () -> all.put("Other", new LinkedHashMap<>())); + } + + @Test + void copyContainerIsIndependentFromOriginal() { + Map nested = new LinkedHashMap<>(); + nested.put("P1", "v1"); + element.setProperty("Parameters", nested); + + Map copy = StructEntrySupport.copyContainer(element, "Parameters"); + copy.put("P2", "v2"); + + Map original = element.getStructuredProperty("Parameters"); + assertFalse(original.containsKey("P2"), "Original container must not be mutated"); + assertTrue(copy.containsKey("P1")); + } + + @Test + void containsEntryAtRoot() { + element.setProperty("icon", "x.png"); + assertTrue(StructEntrySupport.containsEntry(element, null, "icon")); + assertFalse(StructEntrySupport.containsEntry(element, null, "missing")); + } + + @Test + void containsEntryInNestedContainer() { + Map nested = new LinkedHashMap<>(); + nested.put("P1", "v1"); + element.setProperty("Parameters", nested); + + assertTrue(StructEntrySupport.containsEntry(element, "Parameters", "P1")); + assertFalse(StructEntrySupport.containsEntry(element, "Parameters", "P2")); + assertFalse(StructEntrySupport.containsEntry(element, "Unknown", "P1")); + } + + @Test + void containsEntryIsNullSafe() { + assertFalse(StructEntrySupport.containsEntry(null, null, "x")); + assertFalse(StructEntrySupport.containsEntry(element, null, null)); + } + + @Test + void getEntryReadsRootAndNestedValues() { + element.setProperty("icon", "x.png"); + assertEquals("x.png", StructEntrySupport.getEntry(element, null, "icon")); + assertNull(StructEntrySupport.getEntry(element, null, "missing")); + + Map nested = new LinkedHashMap<>(); + nested.put("P1", "v1"); + element.setProperty("Parameters", nested); + + assertEquals("v1", StructEntrySupport.getEntry(element, "Parameters", "P1")); + assertNull(StructEntrySupport.getEntry(element, "Parameters", "P2")); + } +} diff --git a/plc4j/tools/malbec/malbec-s88/core/src/test/java/org/apache/plc4x/malbec/s88/core/UpdatePropertyUseCaseTest.java b/plc4j/tools/malbec/malbec-s88/core/src/test/java/org/apache/plc4x/malbec/s88/core/UpdatePropertyUseCaseTest.java new file mode 100644 index 000000000..e8dfb4c2e --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/core/src/test/java/org/apache/plc4x/malbec/s88/core/UpdatePropertyUseCaseTest.java @@ -0,0 +1,103 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.plc4x.malbec.s88.core; + +import org.apache.plc4x.malbec.s88.api.S88ChangeEvent; +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.api.S88Level; +import org.apache.plc4x.malbec.s88.api.S88PlantModel; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class UpdatePropertyUseCaseTest { + + private S88PlantModel model; + private S88Element element; + + @BeforeEach + void setUp() { + model = mock(S88PlantModel.class); + element = new S88Element() + .setId("Unit1") + .setLevel(S88Level.UNIT); + } + + @Test + void testSetsPropertyAndFiresUpdatedEvent() { + UpdatePropertyUseCase.execute(model, element, "icon", "x.png"); + + assertEquals("x.png", element.getProperty("icon")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(S88ChangeEvent.class); + verify(model).fireChangeEvent(captor.capture()); + assertEquals(S88ChangeEvent.Type.UPDATED, captor.getValue().type()); + assertEquals("icon", captor.getValue().propertyName()); + assertSame(element, captor.getValue().element()); + } + + @Test + void testDoesNotFireEventWhenValueUnchanged() { + element.setProperty("icon", "same"); + + UpdatePropertyUseCase.execute(model, element, "icon", "same"); + + verify(model, never()).fireChangeEvent(any()); + } + + @Test + void testDoesNotFireEventWhenMissingAndNull() { + UpdatePropertyUseCase.execute(model, element, "missing", null); + + verify(model, never()).fireChangeEvent(any()); + } + + @Test + void testRemovesPropertyWhenValueIsNull() { + element.setProperty("icon", "x.png"); + + UpdatePropertyUseCase.execute(model, element, "icon", null); + + assertFalse(element.getProperties().containsKey("icon")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(S88ChangeEvent.class); + verify(model).fireChangeEvent(captor.capture()); + assertEquals(S88ChangeEvent.Type.UPDATED, captor.getValue().type()); + assertEquals("icon", captor.getValue().propertyName()); + } + + @Test + void testIgnoresNullElement() { + UpdatePropertyUseCase.execute(model, null, "icon", "x.png"); + + verify(model, never()).fireChangeEvent(any()); + } + + @Test + void testIgnoresNullAndEmptyKey() { + UpdatePropertyUseCase.execute(model, element, null, "x.png"); + UpdatePropertyUseCase.execute(model, element, "", "x.png"); + + verify(model, never()).fireChangeEvent(any()); + assertTrue(element.getProperties().isEmpty()); + } +} diff --git a/plc4j/tools/malbec/malbec-s88/core/src/test/java/org/apache/plc4x/malbec/s88/core/UpdateStructEntryUseCaseTest.java b/plc4j/tools/malbec/malbec-s88/core/src/test/java/org/apache/plc4x/malbec/s88/core/UpdateStructEntryUseCaseTest.java new file mode 100644 index 000000000..ea1a8e3ff --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/core/src/test/java/org/apache/plc4x/malbec/s88/core/UpdateStructEntryUseCaseTest.java @@ -0,0 +1,140 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.plc4x.malbec.s88.core; + +import org.apache.plc4x.malbec.s88.api.S88ChangeEvent; +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.api.S88Level; +import org.apache.plc4x.malbec.s88.api.S88PlantModel; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class UpdateStructEntryUseCaseTest { + + private S88PlantModel model; + private S88Element element; + + @BeforeEach + void setUp() { + model = mock(S88PlantModel.class); + element = new S88Element() + .setId("Unit1") + .setLevel(S88Level.UNIT); + } + + @Test + void testUpdatesRootLevelEntryAndFiresUpdatedEvent() { + element.setProperty("icon", "old.png"); + + UpdateStructEntryUseCase.execute(model, element, null, "icon", "new.png"); + + assertEquals("new.png", element.getProperty("icon")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(S88ChangeEvent.class); + verify(model).fireChangeEvent(captor.capture()); + assertEquals(S88ChangeEvent.Type.UPDATED, captor.getValue().type()); + assertEquals("icon", captor.getValue().propertyName()); + assertSame(element, captor.getValue().element()); + } + + @Test + void testUpdatesNestedEntryAndFiresUpdatedEvent() { + Map parameters = new LinkedHashMap<>(); + parameters.put("P1", "v1"); + element.setProperty("Parameters", parameters); + + UpdateStructEntryUseCase.execute(model, element, "Parameters", "P1", "v2"); + + assertEquals("v2", element.getStructuredProperty("Parameters").get("P1")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(S88ChangeEvent.class); + verify(model).fireChangeEvent(captor.capture()); + assertEquals(S88ChangeEvent.Type.UPDATED, captor.getValue().type()); + assertEquals("Parameters", captor.getValue().propertyName()); + } + + @Test + void testNestedUpdateIsCopyOnWrite() { + Map parameters = new LinkedHashMap<>(); + parameters.put("P1", "v1"); + element.setProperty("Parameters", parameters); + Map previouslyReadContainer = element.getStructuredProperty("Parameters"); + + UpdateStructEntryUseCase.execute(model, element, "Parameters", "P1", "v2"); + + assertEquals("v1", previouslyReadContainer.get("P1"), + "Previously captured container must not be mutated in place"); + assertEquals("v2", element.getStructuredProperty("Parameters").get("P1")); + } + + @Test + void testDoesNotFireEventWhenRootValueUnchanged() { + element.setProperty("icon", "same"); + + UpdateStructEntryUseCase.execute(model, element, null, "icon", "same"); + + verify(model, never()).fireChangeEvent(any()); + } + + @Test + void testDoesNotFireEventWhenNestedValueUnchanged() { + Map bag = new LinkedHashMap<>(); + bag.put("Type", "REAL"); + Map parameters = new LinkedHashMap<>(); + parameters.put("P1", bag); + element.setProperty("Parameters", parameters); + + UpdateStructEntryUseCase.execute(model, element, "Parameters", "P1", bag); + + verify(model, never()).fireChangeEvent(any()); + } + + @Test + void testThrowsWhenRootEntryMissing() { + assertThrows(IllegalStateException.class, + () -> UpdateStructEntryUseCase.execute(model, element, null, "missing", "v")); + verify(model, never()).fireChangeEvent(any()); + } + + @Test + void testThrowsWhenNestedEntryMissing() { + assertThrows(IllegalStateException.class, + () -> UpdateStructEntryUseCase.execute(model, element, "Parameters", "P1", "v")); + } + + @Test + void testThrowsOnEmptyEntryKey() { + assertThrows(IllegalArgumentException.class, + () -> UpdateStructEntryUseCase.execute(model, element, null, " ", "v")); + } + + @Test + void testThrowsOnNullEntryValue() { + element.setProperty("icon", "x.png"); + assertThrows(IllegalArgumentException.class, + () -> UpdateStructEntryUseCase.execute(model, element, null, "icon", null)); + } +} diff --git a/plc4j/tools/malbec/malbec-s88/data/pom.xml b/plc4j/tools/malbec/malbec-s88/data/pom.xml new file mode 100644 index 000000000..8e9492ad4 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/data/pom.xml @@ -0,0 +1,105 @@ + + + + 4.0.0 + + org.apache.plc4x.malbec.s88 + s88 + 1.1.0-SNAPSHOT + + data + nbm + + + + org.apache.netbeans.utilities + nbm-maven-plugin + true + + eager + + + + org.apache.maven.plugins + maven-jar-plugin + + + ${project.build.outputDirectory}/META-INF/MANIFEST.MF + + + + + + + + org.apache.plc4x.malbec.core + libs + ${project.version} + + + org.netbeans.api + org-netbeans-api-annotations-common + ${netbeans.version} + + + org.netbeans.api + org-openide-util-lookup + ${netbeans.version} + + + org.apache.plc4x.malbec.s88 + api + ${project.version} + + + + org.junit.jupiter + junit-jupiter-api + ${junit.jupiter.version} + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit.jupiter.version} + test + + + org.mockito + mockito-core + 5.18.0 + test + jar + + + org.mockito + mockito-junit-jupiter + ${mockito.version} + test + + + com.fasterxml.jackson.dataformat + jackson-dataformat-xml + 2.19.0 + jar + + + ${project.groupId}:${project.artifactId} + \ No newline at end of file diff --git a/plc4j/tools/malbec/malbec-s88/data/src/main/java/org/apache/plc4x/malbec/s88/data/AXMLRepositoryProvider.java b/plc4j/tools/malbec/malbec-s88/data/src/main/java/org/apache/plc4x/malbec/s88/data/AXMLRepositoryProvider.java new file mode 100644 index 000000000..479deb87a --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/data/src/main/java/org/apache/plc4x/malbec/s88/data/AXMLRepositoryProvider.java @@ -0,0 +1,45 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.plc4x.malbec.s88.data; + +import org.apache.plc4x.malbec.s88.api.S88Repository; +import org.apache.plc4x.malbec.s88.api.S88RepositoryProvider; +import org.apache.plc4x.malbec.s88.api.S88Storage; +import org.apache.plc4x.malbec.s88.data.impl.AXMLRepositoryImpl; +import org.openide.util.lookup.ServiceProvider; + +/** + * + * @author Daniel + */ +@ServiceProvider(service = S88RepositoryProvider.class) +public class AXMLRepositoryProvider implements S88RepositoryProvider { + + @Override + public boolean accepts(String format) { + return "axml".equalsIgnoreCase(format); + } + + @Override + public S88Repository createRepository(S88Storage storage) { + return new AXMLRepositoryImpl(storage); + } + +} + diff --git a/plc4j/tools/malbec/malbec-s88/data/src/main/java/org/apache/plc4x/malbec/s88/data/B2MMLRepositoryProvider.java b/plc4j/tools/malbec/malbec-s88/data/src/main/java/org/apache/plc4x/malbec/s88/data/B2MMLRepositoryProvider.java new file mode 100644 index 000000000..e0a075486 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/data/src/main/java/org/apache/plc4x/malbec/s88/data/B2MMLRepositoryProvider.java @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.plc4x.malbec.s88.data; + +import org.apache.plc4x.malbec.s88.api.S88Repository; +import org.apache.plc4x.malbec.s88.api.S88RepositoryProvider; +import org.apache.plc4x.malbec.s88.api.S88Storage; +import org.apache.plc4x.malbec.s88.data.impl.B2MMLRepositoryImpl; +import org.openide.util.lookup.ServiceProvider; + +/** + * + * @author Daniel + */ +@ServiceProvider(service = S88RepositoryProvider.class) +public class B2MMLRepositoryProvider implements S88RepositoryProvider { + + @Override + public boolean accepts(String format) { + return "xml".equalsIgnoreCase(format); + } + + @Override + public S88Repository createRepository(S88Storage storage) { + return new B2MMLRepositoryImpl(storage); + } + +} diff --git a/plc4j/tools/malbec/malbec-s88/data/src/main/java/org/apache/plc4x/malbec/s88/data/impl/AXMLRepositoryImpl.java b/plc4j/tools/malbec/malbec-s88/data/src/main/java/org/apache/plc4x/malbec/s88/data/impl/AXMLRepositoryImpl.java new file mode 100644 index 000000000..4bae3e8eb --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/data/src/main/java/org/apache/plc4x/malbec/s88/data/impl/AXMLRepositoryImpl.java @@ -0,0 +1,391 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.plc4x.malbec.s88.data.impl; + + +import org.apache.plc4x.malbec.s88.api.*; +import org.apache.xmlbeans.XmlException; +import org.apache.xmlbeans.XmlOptions; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +import javax.xml.transform.*; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.text.Normalizer; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.regex.Pattern; +import rockwell.areaModel.*; +import rockwell.areaModel.impl.TagClassImpl; + +/** + * @author Daniel + *
+ * Implementation of S88Repository for .axml format. + *
+ * 1. Exports the Malbec model to a .axml file so Rockwell + * ft batch equipment editor can import it. + *
+ * 2. Imports an .axml file from Rockwell ft + * batch equipment editor to malbec model. + */ + +public class AXMLRepositoryImpl implements S88Repository { + private final S88Storage storage; + + public AXMLRepositoryImpl(S88Storage storage) { + this.storage = storage; + } + + /** + * Turns areaModel from Rockwell .axml format into + * malbec model by mapping a Rockwell element + * to a S88Element. + * + * @return S88Element + */ + @Override + public S88PlantModel loadPlant() { + try (InputStream input = storage.openInput()) { + AreaModelDocument doc = AreaModelDocument.Factory.parse(input); + AreaModelDocument.AreaModel areaModel = doc.getAreaModel(); + S88Element root = mapToApi(areaModel); + S88PlantModel model = new S88PlantModel(root); + + for (S88ElementClass ec : root.getElementClasses()){ + model.registerClass(ec); + } + + if (areaModel.getEnumerationSetArray() != null){ + for(EnumerationSet enumerationSet : areaModel.getEnumerationSetArray()){ + S88Enumeration e = new S88Enumeration(enumerationSet.getUniqueName()); + for(EnumerationSet.Member member : enumerationSet.getMemberArray()){ + e.setValue(member.getName(), member.getOrdinal().intValue()); + } + model.registerEnumeration(e); + } + } + + return model; + } catch (IOException | XmlException e) { + throw new RuntimeException(e); + } + } + + /** + * Turns physical model from Malbec into + * Rockwell areaModel .axml format. + * + * @param model malbec physical model. + */ + @Override + public void savePlant(S88PlantModel model) { + try (OutputStream output = storage.openOutput()) { + + + InputStream template = getClass() + .getResourceAsStream("/org/apache/plc4x/malbec/s88/data/template.axml"); + if (template == null) return; + + + AreaModelDocument doc = AreaModelDocument.Factory.parse(template); + AreaModelDocument.AreaModel areaModel = doc.getAreaModel(); + + S88Element root = model.getRoot(); + if (root == null) return; + + + areaModel.getArea().setUniqueName(cleanText(root.getId())); + + int nextId = 1; + + + for (S88ElementClass ec : model.getClasses().values()) { + if (ec.getTargetLevel() == S88Level.PROCESSCELL) { + ProcessCellClass pcc = areaModel.addNewProcessCellClass(); + pcc.setUniqueName(cleanText(ec.getName())); + pcc.setIconFilename(""); + + } else if (ec.getTargetLevel() == S88Level.UNIT) { + UnitClass uc = areaModel.addNewUnitClass(); + uc.setUniqueName(cleanText(ec.getName())); + uc.setIconFilename(""); + } + } + + + for (S88Element cellElement : root.getChildren()) { + if (cellElement.getLevel() != S88Level.PROCESSCELL) continue; + + rockwell.areaModel.ProcessCell pc = areaModel.addNewProcessCell(); + pc.setXPos(parseIntOrDefault(String.valueOf(cellElement.getProperty("xPos")), 100)); + pc.setYPos(parseIntOrDefault(String.valueOf(cellElement.getProperty("yPos")), 100)); + pc.setUniqueName(cleanText(cellElement.getId())); + pc.setClass1(cleanText(cellElement.getElementClass().getName())); + pc.setUniqueID(nextId++); + pc.setLogix5000UID(0); + pc.setMaxOwners(1); + + for (int i = 0; i < 5; i++) pc.addNewCrossInvocationString(); + for (int i = 0; i < 5; i++) pc.addNewHyperlinkString(); + pc.setERPAlias(""); + + for (S88Element unitElement : cellElement.getChildren()) { + if (unitElement.getLevel() != S88Level.UNIT) continue; + + rockwell.areaModel.Unit u = areaModel.addNewUnit(); + u.setXPos(parseIntOrDefault(String.valueOf(unitElement.getProperty("xPos")), 150)); + u.setYPos(parseIntOrDefault(String.valueOf(unitElement.getProperty("yPos")), 150)); + u.setUniqueName(cleanText(unitElement.getId())); + u.setClass1(cleanText(unitElement.getElementClass().getName())); + u.setUniqueID(nextId++); + u.setLogix5000UID(0); + u.setServer(""); + u.setMaxOwners(1); + for (int i = 0; i < 5; i++) u.addNewCrossInvocationString(); + for (int i = 0; i < 5; i++) u.addNewHyperlinkString(); + u.setERPAlias(""); + + pc.addConfiguredUnitName(cleanText(unitElement.getId())); + } + } + + + XmlOptions options = new XmlOptions(); + options.setSavePrettyPrint(); + options.setSaveAggressiveNamespaces(); + doc.save(output, options); + + } catch (IOException | XmlException e) { + throw new RuntimeException(e); + } + } + + private int parseIntOrDefault(String value, int defaultValue) { + if (value == null || value.isEmpty()) return defaultValue; + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + private static String unitsOrEnum(String engineeringUnits, String enumerationSetName) { + String value = (engineeringUnits != null && !engineeringUnits.trim().isEmpty()) + ? engineeringUnits : enumerationSetName; + return value != null ? value : ""; + } + + public String cleanText(String original) { + if (original == null) { + return ""; + } + String text = original.toUpperCase().trim(); + String normalized = Normalizer.normalize(text, Normalizer.Form.NFD); + Pattern accent = Pattern.compile("\\p{InCombiningDiacriticalMarks}+"); + String accentLess = accent.matcher(normalized).replaceAll(""); + accentLess = accentLess.replace("Ãą", "n").replace("Ñ", "N"); + String result = accentLess.replaceAll("\\s+", "_"); + + result = result.replaceAll("[^A-Za-z0-9_]", ""); + + return result; + } + + + + private S88Element mapToApi(AreaModelDocument.AreaModel areaModel) { + S88Element root = new S88Element(); + if (areaModel == null) return root; + + root.setId(areaModel.getArea().getUniqueName()); + root.setLevel(S88Level.AREA); + + if (areaModel.getProcessCellClassArray() != null) { + for (ProcessCellClass pcc : areaModel.getProcessCellClassArray()) { + S88ElementClass ec = new S88ElementClass(); + ec.setName(pcc.getUniqueName()); + ec.setTargetLevel(S88Level.PROCESSCELL); + root.addElementClass(ec); + } + } + if (areaModel.getUnitClassArray() != null) { + for (UnitClass uc : areaModel.getUnitClassArray()) { + S88ElementClass ec = new S88ElementClass(); + ec.setName(uc.getUniqueName()); + ec.setTargetLevel(S88Level.UNIT); + + for (String utc : uc.getConfiguredUnitTagClassNameArray()){ + Arrays.stream(areaModel.getTagClassArray()) + .filter(tagClass -> tagClass != null && tagClass.getUniqueName().equals(utc)) + .findFirst(). + ifPresent(tagClass -> { + Map property = new LinkedHashMap<>(); + property.put("Type", DataType.fromString(tagClass.getType().toString())); + property.put("Eng_Units/Enum", unitsOrEnum(tagClass.getEngineeringUnits(), tagClass.getEnumerationSetName())); + ec.setProperty(tagClass.getUniqueName(), property); + }); + } + + root.addElementClass(ec); + } + } + + if(areaModel.getRecipePhaseArray() != null){ + for (AreaModelDocument.AreaModel.RecipePhase rp : areaModel.getRecipePhaseArray()) { + S88ElementClass ec = new S88ElementClass(); + ec.setName(rp.getUniqueName()); + ec.setTargetLevel(S88Level.EQUIPMENTMODULE); + root.addElementClass(ec); + + Map paramsMap = new LinkedHashMap<>(); + + for (RecipePhaseParameter rpp : rp.getRecipeParameterArray()){ + Map params = new LinkedHashMap<>(); + params.put("Type", DataType.fromString(rpp.getType().toString())); + params.put("Eng_Units/Enum", unitsOrEnum(rpp.getEngineeringUnits(), rpp.getEnumerationSetName())); + String def; + if (rpp.isSetIntegerDefault()) def = String.valueOf(rpp.getIntegerDefault()); + else if (rpp.isSetRealDefault()) def = rpp.getRealDefault(); + else if (rpp.isSetStringDefault()) def = rpp.getStringDefault(); + else if (rpp.isSetEnumerationDefault()) def = rpp.getEnumerationDefault(); + else def = null; + if (def != null) params.put("Default", def); + + if (rpp.isSetIntegerMin()) params.put("Min", String.valueOf(rpp.getIntegerMin())); + else if (rpp.isSetRealMin()) params.put("Min", rpp.getRealMin()); + + if (rpp.isSetIntegerMax()) params.put("Max", String.valueOf(rpp.getIntegerMax())); + else if (rpp.isSetRealMax()) params.put("Max", rpp.getRealMax()); + + paramsMap.put(rpp.getName(), params); + } + + ec.setProperty("Parameters", paramsMap); + + Map reportsMap = new LinkedHashMap<>(); + for (RecipePhaseReport rpr : rp.getReportParameterArray()){ + Map reports = new LinkedHashMap<>(); + reports.put("Type", DataType.fromString(rpr.getType().toString())); + reports.put("Eng_Units/Enum", unitsOrEnum(rpr.getEngineeringUnits(), rpr.getEnumerationSetName())); + reportsMap.put(rpr.getName(), reports); + } + + ec.setProperty("Reports", reportsMap); + } + } + + if (areaModel.getProcessCellArray() != null) { + for (rockwell.areaModel.ProcessCell pc : areaModel.getProcessCellArray()) { + S88Element pcElement = new S88Element(); + pcElement.setId(pc.getUniqueName()); + pcElement.setLevel(S88Level.PROCESSCELL); + pcElement.setProperty("xPos", String.valueOf(pc.getXPos())); + pcElement.setProperty("yPos", String.valueOf(pc.getYPos())); + + + String className = pc.getClass1(); + if (className != null && !className.isEmpty()) { + root.getElementClasses().stream() + .filter(c -> c.getName().equals(className)) + .findFirst().ifPresent(pcElement::setClass); + } + + + if (pc.sizeOfConfiguredUnitNameArray() > 0 && areaModel.getUnitArray() != null) { + for (String unitName : pc.getConfiguredUnitNameArray()) { + for (rockwell.areaModel.Unit u : areaModel.getUnitArray()) { + if (u.getUniqueName().equals(unitName)) { + S88Element unitElement = new S88Element(); + unitElement.setId(u.getUniqueName()); + unitElement.setLevel(S88Level.UNIT); + unitElement.setProperty("xPos", String.valueOf(u.getXPos())); + unitElement.setProperty("yPos", String.valueOf(u.getYPos())); + pcElement.addChild(unitElement); + + if(u.getTagArray() != null) { + for(UnitTag tag : u.getTagArray()) { + Map structProp = new LinkedHashMap<>(); + structProp.put("Type", DataType.fromString(Objects.toString(tag.getDataType(), ""))); + structProp.put("Eng_Units/Enum", unitsOrEnum(tag.getEngineeringUnits(), tag.getEnumerationSetName())); + structProp.put("ItemName", Objects.toString(tag.getReadItemName(), "")); + + unitElement.setProperty(tag.getUniqueName(), structProp); + } + } + + String unitClassName = u.getClass1(); + if (unitClassName != null && !unitClassName.isEmpty()) { + root.getElementClasses().stream() + .filter(c -> c.getName().equals(unitClassName)) + .findFirst().ifPresent(unitElement::setClass); + } + + if(u.sizeOfConfiguredEquipmentModuleNameArray() > 0 && areaModel.getEquipmentModuleArray() != null) { + for (String eqmName : u.getConfiguredEquipmentModuleNameArray()) { + for (rockwell.areaModel.EquipmentModule eqm : areaModel.getEquipmentModuleArray()) { + if (eqm.getUniqueName().equals(eqmName)) { + S88Element eqmElement = new S88Element(); + eqmElement.setId(eqm.getUniqueName()); + eqmElement.setLevel(S88Level.EQUIPMENTMODULE); + eqmElement.setProperty("xPos", String.valueOf(eqm.getXPos())); + eqmElement.setProperty("yPos", String.valueOf(eqm.getYPos())); + unitElement.addChild(eqmElement); + + + String eqmClassName = eqm.getRecipePhase(); + if (eqmClassName != null && !eqmClassName.isEmpty()) { + root.getElementClasses().stream() + .filter(c -> c.getName().equals(eqmClassName)) + .findFirst().ifPresent(eqmElement::setClass); + } + + for (var entry : eqmElement.getElementClass().getProperties().entrySet()) { + eqmElement.setProperty(entry.getKey(), entry.getValue()); + } + + break; + } + } + } + } + + + break; + } + } + } + } + + root.addChild(pcElement); + } + } + + return root; + } + +} diff --git a/plc4j/tools/malbec/malbec-s88/data/src/main/java/org/apache/plc4x/malbec/s88/data/impl/B2MMLRepositoryImpl.java b/plc4j/tools/malbec/malbec-s88/data/src/main/java/org/apache/plc4x/malbec/s88/data/impl/B2MMLRepositoryImpl.java new file mode 100644 index 000000000..7d72da09a --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/data/src/main/java/org/apache/plc4x/malbec/s88/data/impl/B2MMLRepositoryImpl.java @@ -0,0 +1,314 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.plc4x.malbec.s88.data.impl; + +import org.apache.plc4x.malbec.s88.api.*; +import org.apache.xmlbeans.XmlException; +import org.apache.xmlbeans.XmlOptions; +import org.mesa.xml.b2MML.EquipmentPropertyType; +import org.mesa.xml.b2MML.EquipmentType; +import org.mesa.xml.b2MML.ValueType; +import org.mesa.xml.b2MML.EquipmentInformationDocument; +import org.mesa.xml.b2MML.EquipmentInformationType; +import org.mesa.xml.b2MML.EquipmentClassType; +import org.mesa.xml.b2MML.EquipmentClassPropertyType; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Implementation of S88Repository for B2MML XML format. + * Manages the I/O operations for Malbec default model. + */ +public class B2MMLRepositoryImpl implements S88Repository { + + private final S88Storage storage; + + public B2MMLRepositoryImpl(S88Storage storage) { + this.storage = storage; + } + + @Override + public S88PlantModel loadPlant() { + try (InputStream input = storage.openInput()){ + EquipmentInformationDocument doc = EquipmentInformationDocument.Factory.parse(input); + EquipmentInformationType info = doc.getEquipmentInformation(); + + Map tempClasses = new LinkedHashMap<>(); + + Map> classesByTargetLevel = new LinkedHashMap<>(); + + for (EquipmentClassType ecXml : info.getEquipmentClassArray()) { + S88ElementClass ec = new S88ElementClass(); + ec.setName(ecXml.getID().getStringValue()); + for (EquipmentClassPropertyType prop : ecXml.getEquipmentClassPropertyArray()) { + if (prop.getID() == null) continue; + String id = prop.getID().getStringValue(); + if ("targetLevel".equals(id)) { + if (prop.sizeOfValueArray() > 0 && prop.getValueArray(0).getValueString() != null) { + ec.setTargetLevel(S88Level.fromTxt(prop.getValueArray(0).getValueString().getStringValue())); + } + } else { + ec.setProperty(id, readClassPropertyValue(prop)); + } + } + tempClasses.put(ec.getName(), ec); + if (ec.getTargetLevel() != null) { + classesByTargetLevel + .computeIfAbsent(ec.getTargetLevel(), k -> new ArrayList<>()) + .add(ec); + } + } + + EquipmentType rootXml = info.sizeOfEquipmentArray() > 0 ? info.getEquipmentArray(0) : null; + S88Element root = rootXml != null ? mapToApi(rootXml, tempClasses, classesByTargetLevel) : new S88Element(); + + S88PlantModel model = new S88PlantModel(root); + for (S88ElementClass ec : tempClasses.values()) { + model.registerClass(ec); + } + + return model; + + } catch (XmlException | IOException e) { + java.util.logging.Logger.getLogger(B2MMLRepositoryImpl.class.getName()) + .log(java.util.logging.Level.WARNING, "Failed to load plant model", e); + } + return null; + } + + @Override + public void savePlant(S88PlantModel model) { + try (OutputStream output = storage.openOutput()){ + + EquipmentInformationDocument doc = EquipmentInformationDocument.Factory.newInstance(); + EquipmentInformationType info = doc.addNewEquipmentInformation(); + + for (S88ElementClass ec : model.getClasses().values()) { + EquipmentClassType ecXml = info.addNewEquipmentClass(); + ecXml.addNewID().setStringValue(ec.getName()); + if (ec.getTargetLevel() != null) { + EquipmentClassPropertyType prop = ecXml.addNewEquipmentClassProperty(); + prop.addNewID().setStringValue("targetLevel"); + prop.addNewValue().addNewValueString().setStringValue(mapLevelToB2MML(ec.getTargetLevel())); + } + + for (var entry : ec.getProperties().entrySet()) { + writeClassProperty(ecXml.addNewEquipmentClassProperty(), entry.getKey(), entry.getValue()); + } + + } + + EquipmentType rootXml = info.addNewEquipment(); + mapToXml(model.getRoot(), rootXml); + + XmlOptions options = new XmlOptions(); + options.setSavePrettyPrint(); + options.setSaveAggressiveNamespaces(); + doc.save(output, options); + } catch (IOException e) { + java.util.logging.Logger.getLogger(B2MMLRepositoryImpl.class.getName()) + .log(java.util.logging.Level.WARNING, "Failed to save plant model", e); + } + } + + private S88Element mapToApi(EquipmentType xml, Map classMap, + Map> classesByTargetLevel) { + S88Level level = mapLevelFromB2MML( + xml.getEquipmentLevel() != null ? xml.getEquipmentLevel().getStringValue() : ""); + + + S88Element element = new S88Element(); + element.setId(xml.getID() != null ? xml.getID().getStringValue() : "unknown"); + element.setLevel(level); + + if (xml.sizeOfEquipmentClassIDArray() > 0 && classMap != null) { + String className = xml.getEquipmentClassIDArray(0).getStringValue(); + element.setClass(classMap.get(className)); + } + + + if (classesByTargetLevel != null && element.getLevel() != null && !element.getLevel().isEmpty()) { + S88Level childLevel = element.getLevel().getChildLevel(); + List matches = classesByTargetLevel.get(childLevel); + if (matches != null) { + for (S88ElementClass ec : matches) { + element.addElementClass(ec); + } + } + } + + buildProperties(xml.getEquipmentPropertyArray(), element); + buildHierarchy(xml.getEquipmentChildArray(), element, classMap, classesByTargetLevel); + return element; + } + + private void mapToXml(S88Element element, EquipmentType equipment) { + equipment.addNewID().setStringValue(element.getId()); + S88Level level = element.getLevel(); + if (level != null && !level.isEmpty()) { + equipment.addNewEquipmentLevel().setStringValue(mapLevelToB2MML(level)); + } + + if (element.getElementClass() != null) { + equipment.addNewEquipmentClassID().setStringValue(element.getElementClass().getName()); + } + Object desc = element.getProperty("description"); + if (desc != null && !String.valueOf(desc).isEmpty()) { + equipment.addNewDescription().setStringValue(String.valueOf(desc)); + } + + for (var entry : element.getProperties().entrySet()) { + writeProperty(equipment.addNewEquipmentProperty(), entry.getKey(), entry.getValue()); + } + + + for (S88Element childApi : element.getChildren()) { + mapToXml(childApi, equipment.addNewEquipmentChild()); + } + } + + + private void writeProperty(EquipmentPropertyType propXml, String key, Object value) { + propXml.addNewID().setStringValue(key); + if (value instanceof Map nested) { + for (var e : nested.entrySet()) { + writeProperty(propXml.addNewEquipmentPropertyChild(), String.valueOf(e.getKey()), e.getValue()); + } + } else if (value != null) { + ValueType v = propXml.addNewValue(); + v.addNewValueString().setStringValue(String.valueOf(value)); + v.addNewDataType().setStringValue(inferDataType(value)); + } + } + + private void writeClassProperty(EquipmentClassPropertyType propXml, String key, Object value) { + propXml.addNewID().setStringValue(key); + if (value instanceof Map nested) { + for (var e : nested.entrySet()) { + writeClassProperty(propXml.addNewEquipmentClassPropertyChild(), String.valueOf(e.getKey()), e.getValue()); + } + } else if (value != null) { + ValueType v = propXml.addNewValue(); + v.addNewValueString().setStringValue(String.valueOf(value)); + v.addNewDataType().setStringValue(inferDataType(value)); + } + } + + private Object readClassPropertyValue(EquipmentClassPropertyType prop) { + if (prop.sizeOfEquipmentClassPropertyChildArray() > 0) { + Map nested = new LinkedHashMap<>(); + for (EquipmentClassPropertyType child : prop.getEquipmentClassPropertyChildArray()) { + if (child.getID() == null) continue; + nested.put(child.getID().getStringValue(), readClassPropertyValue(child)); + } + return nested; + } + if (prop.sizeOfValueArray() > 0 && prop.getValueArray(0).getValueString() != null) { + ValueType val = prop.getValueArray(0); + String dataType = val.getDataType() != null ? val.getDataType().getStringValue() : null; + return parseTypedValue(val.getValueString().getStringValue(), dataType); + } + return null; + } + + private String inferDataType(Object value) { + if (value instanceof Integer || value instanceof Long) return "int"; + if (value instanceof Double || value instanceof Float) return "double"; + if (value instanceof Boolean) return "boolean"; + return "string"; + } + + private String mapLevelToB2MML(S88Level level) { + return switch (level) { + case AREA -> "Area"; + case PROCESSCELL -> "ProcessCell"; + case UNIT -> "Unit"; + case EQUIPMENTMODULE -> "EquipmentModule"; + default -> ""; + }; + } + + private S88Level mapLevelFromB2MML(String txt) { + if (txt == null || txt.isBlank()) return S88Level.NULL; + return switch (txt) { + case "Area" -> S88Level.AREA; + case "ProcessCell" -> S88Level.PROCESSCELL; + case "Unit" -> S88Level.UNIT; + case "EquipmentModule" -> S88Level.EQUIPMENTMODULE; + default -> S88Level.fromTxt(txt); + }; + } + + private void buildHierarchy(EquipmentType[] equipment, S88Element element, Map classMap, + Map> classesByTargetLevel) { + for (EquipmentType childXml : equipment) { + element.addChild(mapToApi(childXml, classMap, classesByTargetLevel)); + } + } + + private void buildProperties(EquipmentPropertyType[] properties, S88Element element) { + readPropertiesMap(properties).forEach(element::setProperty); + } + + private Map readPropertiesMap(EquipmentPropertyType[] properties) { + Map result = new LinkedHashMap<>(); + for (EquipmentPropertyType prop : properties) { + if (prop.getID() == null) continue; + result.put(prop.getID().getStringValue(), readPropertyValue(prop)); + } + return result; + } + + private Object readPropertyValue(EquipmentPropertyType prop) { + if (prop.sizeOfEquipmentPropertyChildArray() > 0) { + Map nested = new LinkedHashMap<>(); + for (EquipmentPropertyType child : prop.getEquipmentPropertyChildArray()) { + if (child.getID() == null) continue; + nested.put(child.getID().getStringValue(), readPropertyValue(child)); + } + return nested; + } + if (prop.sizeOfValueArray() > 0 && prop.getValueArray(0).getValueString() != null) { + ValueType val = prop.getValueArray(0); + String dataType = val.getDataType() != null ? val.getDataType().getStringValue() : null; + return parseTypedValue(val.getValueString().getStringValue(), dataType); + } + return null; + } + + private Object parseTypedValue(String raw, String dataType) { + if (raw == null) return null; + try { + return switch (dataType != null ? dataType.toLowerCase() : "string") { + case "int", "integer", "long" -> Long.parseLong(raw); + case "double", "float", "real" -> Double.parseDouble(raw); + case "boolean", "bool" -> Boolean.parseBoolean(raw); + default -> raw; + }; + } catch (NumberFormatException e) { + return raw; + } + } +} \ No newline at end of file diff --git a/plc4j/tools/malbec/malbec-s88/data/src/main/nbm/manifest.mf b/plc4j/tools/malbec/malbec-s88/data/src/main/nbm/manifest.mf new file mode 100644 index 000000000..668c3e2b5 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/data/src/main/nbm/manifest.mf @@ -0,0 +1,3 @@ +Manifest-Version: 1.0 +OpenIDE-Module-Localizing-Bundle: org/apache/plc4x/malbec/s88/data/Bundle.properties + diff --git a/plc4j/tools/malbec/malbec-s88/data/src/main/resources/org/apache/plc4x/malbec/s88/data/Bundle.properties b/plc4j/tools/malbec/malbec-s88/data/src/main/resources/org/apache/plc4x/malbec/s88/data/Bundle.properties new file mode 100644 index 000000000..e80f6f936 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/data/src/main/resources/org/apache/plc4x/malbec/s88/data/Bundle.properties @@ -0,0 +1,23 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +#Localized module labels. Defaults taken from POM (, , ) if unset. +#OpenIDE-Module-Name= +#OpenIDE-Module-Short-Description= +#OpenIDE-Module-Long-Description= +#OpenIDE-Module-Display-Category= +#Wed May 27 14:16:35 VET 2026 diff --git a/plc4j/tools/malbec/malbec-s88/data/src/main/resources/org/apache/plc4x/malbec/s88/data/template.axml b/plc4j/tools/malbec/malbec-s88/data/src/main/resources/org/apache/plc4x/malbec/s88/data/template.axml new file mode 100644 index 000000000..63e40a06a --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/data/src/main/resources/org/apache/plc4x/malbec/s88/data/template.axml @@ -0,0 +1,2 @@ + +1.00AREA1YES_NO1YES1NO0PHASE_FAILURES0$TIMER_TYPE1COUNT_DOWN0COUNT_UP1$HOLD_BEHAVIOR1RETENTIVE1RESET2CONTINUE0REPORTING_CONTEXTS1NULL0STATICBegin Approvals-111Used to formally begin the approval process.Confirm Author-220Confirmation by the author of the recipe that it is ready for review.Review Recipe-330Confirmation that the recipe has been reviewed by the signer(s)Approve Recipe-440Confirmation that the recipe has been approved by the signer(s)Release Recipe as Step-521Confirmation that this step can be available for use by others.Release Recipe to Production-621Confirmation that this recipe can be available to put on the batch list.Release Recipe as Step-711Confirmation that this step can be available for use by others.Release Recipe to Production-811Confirmation that this recipe can be available to put on the batch list.00NOT_AVAILABLEPCELL_CLS1CFactory1.BMPUNIT_CLS1UBoiler.BMP$TIMER_DN_SECONDPstopwatch.bmptrueStandard3200true$TIMER_TYPE1Enumerationfalse65535COUNT_DOWN$TIMER_TYPE$HOLD_BEHAVIOR2Enumerationfalse65535CONTINUE$HOLD_BEHAVIOR$SETPOINT3Realfalse65535300104147200SECONDSNo_LimitsfalseNoneSignatureTemplateSignatureTemplateSignatureTemplate$ELAPSED_TIME1Real065535SECONDSNo_LimitsfalseNoneSignatureTemplateSignatureTemplateSignatureTemplateAbsolute$REMAINING_TIME2Real065535SECONDSNo_LimitsfalseNoneSignatureTemplateSignatureTemplateSignatureTemplateAbsolute$TIMER_DN_MINUTEPstopwatch.bmptrueStandard3200true$TIMER_TYPE1Enumerationfalse65535COUNT_DOWN$TIMER_TYPE$HOLD_BEHAVIOR2Enumerationfalse65535CONTINUE$HOLD_BEHAVIOR$SETPOINT3Realfalse65535200.1769120MINUTESNo_LimitsfalseNoneSignatureTemplateSignatureTemplateSignatureTemplate$ELAPSED_TIME1Real065535MINUTESNo_LimitsfalseNoneSignatureTemplateSignatureTemplateSignatureTemplateAbsolute$REMAINING_TIME2Real065535MINUTESNo_LimitsfalseNoneSignatureTemplateSignatureTemplateSignatureTemplateAbsolute$TIMER_DN_HOURPstopwatch.bmptrueStandard3200true$TIMER_TYPE1Enumerationfalse65535COUNT_DOWN$TIMER_TYPE$HOLD_BEHAVIOR2Enumerationfalse65535CONTINUE$HOLD_BEHAVIOR$SETPOINT3Realfalse6553550.0031152HOURSNo_LimitsfalseNoneSignatureTemplateSignatureTemplateSignatureTemplate$ELAPSED_TIME1Real065535HOURSNo_LimitsfalseNoneSignatureTemplateSignatureTemplateSignatureTemplateAbsolute$REMAINING_TIME2Real065535HOURSNo_LimitsfalseNoneSignatureTemplateSignatureTemplateSignatureTemplateAbsolute$TIMER_DN_DAYPstopwatch.bmptrueStandard3200true$TIMER_TYPE1Enumerationfalse65535COUNT_DOWN$TIMER_TYPE$HOLD_BEHAVIOR2Enumerationfalse65535CONTINUE$HOLD_BEHAVIOR$SETPOINT3Realfalse655355048DAYSNo_LimitsfalseNoneSignatureTemplateSignatureTemplateSignatureTemplate$ELAPSED_TIME1Real065535DAYSNo_LimitsfalseNoneSignatureTemplateSignatureTemplateSignatureTemplateAbsolute$REMAINING_TIME2Real065535DAYSNo_LimitsfalseNoneSignatureTemplateSignatureTemplateSignatureTemplateAbsolute$TIMER_UP_SECONDPstopwatch.bmptrueStandard2100true$TIMER_TYPE1Enumerationfalse65535COUNT_UP$TIMER_TYPE$HOLD_BEHAVIOR2Enumerationfalse65535CONTINUE$HOLD_BEHAVIOR$ELAPSED_TIME1Real065535SECONDSNo_LimitsfalseNoneSignatureTemplateSignatureTemplateSignatureTemplateAbsolute$TIMER_UP_MINUTEPstopwatch.bmptrueStandard2100true$TIMER_TYPE1Enumerationfalse65535COUNT_UP$TIMER_TYPE$HOLD_BEHAVIOR2Enumerationfalse65535CONTINUE$HOLD_BEHAVIOR$ELAPSED_TIME1Real065535MINUTESNo_LimitsfalseNoneSignatureTemplateSignatureTemplateSignatureTemplateAbsolute$TIMER_UP_HOURPstopwatch.bmptrueStandard2100true$TIMER_TYPE1Enumerationfalse65535COUNT_UP$TIMER_TYPE$HOLD_BEHAVIOR2Enumerationfalse65535CONTINUE$HOLD_BEHAVIOR$ELAPSED_TIME1Real065535HOURSNo_LimitsfalseNoneSignatureTemplateSignatureTemplateSignatureTemplateAbsolute$TIMER_UP_DAYPstopwatch.bmptrueStandard2100true$TIMER_TYPE1Enumerationfalse65535COUNT_UP$TIMER_TYPE$HOLD_BEHAVIOR2Enumerationfalse65535CONTINUE$HOLD_BEHAVIOR$ELAPSED_TIME1Real065535DAYSNo_LimitsfalseNoneSignatureTemplateSignatureTemplateSignatureTemplateAbsolute$NULLPstopwatch.bmptrueStandard0000true diff --git a/plc4j/tools/malbec/malbec-s88/data/src/test/java/org/apache/plc4x/malbec/s88/data/impl/B2MMLRepositoryImplTest.java b/plc4j/tools/malbec/malbec-s88/data/src/test/java/org/apache/plc4x/malbec/s88/data/impl/B2MMLRepositoryImplTest.java new file mode 100644 index 000000000..b584787c0 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/data/src/test/java/org/apache/plc4x/malbec/s88/data/impl/B2MMLRepositoryImplTest.java @@ -0,0 +1,624 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.plc4x.malbec.s88.data.impl; + +import org.apache.plc4x.malbec.s88.api.*; +import org.apache.xmlbeans.XmlException; +import org.junit.jupiter.api.Test; +import org.mesa.xml.b2MML.EquipmentInformationDocument; +import org.mesa.xml.b2MML.EquipmentInformationType; +import org.mesa.xml.b2MML.EquipmentType; + +import java.io.*; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class B2MMLRepositoryImplTest { + + // ========== Test Fixtures ========== + + /** + * In-memory S88Storage that captures the XML output so it can be re-read, + * enabling true round-trip tests without files or mocks. + */ + private static class InMemoryStorage implements S88Storage { + byte[] data; + + @Override + public InputStream openInput() { + return data != null + ? new ByteArrayInputStream(data) + : new ByteArrayInputStream(new byte[0]); + } + + @Override + public OutputStream openOutput() { + return new ByteArrayOutputStream() { + @Override + public void close() throws IOException { + super.close(); + data = toByteArray(); + } + }; + } + } + + private static B2MMLRepositoryImpl newRepo(InMemoryStorage storage) { + return new B2MMLRepositoryImpl(storage); + } + + private static S88Element element(String id, S88Level level) { + return new S88Element() + .setId(id) + .setLevel(level); + } + + private static S88PlantModel model(S88Element root) { + return new S88PlantModel(root); + } + + // ========== Round-trip: Basic Structure ========== + + @Test + void roundTripRootOnly() { + var storage = new InMemoryStorage(); + var repo = newRepo(storage); + + repo.savePlant(model(element("Plant", S88Level.NULL))); + + S88PlantModel loaded = repo.loadPlant(); + assertNotNull(loaded); + assertEquals("Plant", loaded.getRoot().getId()); + assertEquals(S88Level.NULL, loaded.getRoot().getLevel()); + assertTrue(loaded.getRoot().getChildren().isEmpty()); + } + + @Test + void roundTripRootWithLevel() { + var storage = new InMemoryStorage(); + var repo = newRepo(storage); + + repo.savePlant(model(element("MyArea", S88Level.AREA))); + + S88PlantModel loaded = repo.loadPlant(); + assertNotNull(loaded); + assertEquals("MyArea", loaded.getRoot().getId()); + assertEquals(S88Level.AREA, loaded.getRoot().getLevel()); + } + + @Test + void roundTripWithSingleChild() { + var storage = new InMemoryStorage(); + var repo = newRepo(storage); + + S88Element root = element("Root", S88Level.AREA); + root.addChild(element("Child", S88Level.PROCESSCELL)); + repo.savePlant(model(root)); + + S88PlantModel loaded = repo.loadPlant(); + assertNotNull(loaded); + List children = loaded.getRoot().getChildren(); + assertEquals(1, children.size()); + assertEquals("Child", children.get(0).getId()); + assertEquals(S88Level.PROCESSCELL, children.get(0).getLevel()); + assertSame(loaded.getRoot(), children.get(0).getParent()); + } + + @Test + void roundTripDeepHierarchy() { + var storage = new InMemoryStorage(); + var repo = newRepo(storage); + + S88Element area = element("Area1", S88Level.AREA); + S88Element pc = element("PC1", S88Level.PROCESSCELL); + S88Element unit = element("Unit1", S88Level.UNIT); + S88Element em = element("EM1", S88Level.EQUIPMENTMODULE); + + area.addChild(pc); + pc.addChild(unit); + unit.addChild(em); + repo.savePlant(model(area)); + + S88PlantModel loaded = repo.loadPlant(); + assertNotNull(loaded); + + S88Element l1 = loaded.getRoot(); + assertEquals("Area1", l1.getId()); + assertEquals(1, l1.getChildren().size()); + + S88Element l2 = l1.getChildren().get(0); + assertEquals("PC1", l2.getId()); + assertSame(l1, l2.getParent()); + assertEquals(1, l2.getChildren().size()); + + S88Element l3 = l2.getChildren().get(0); + assertEquals("Unit1", l3.getId()); + assertEquals(1, l3.getChildren().size()); + + S88Element l4 = l3.getChildren().get(0); + assertEquals("EM1", l4.getId()); + assertEquals(0, l4.getChildren().size()); + } + + + @Test + void roundTripMultipleChildren() { + var storage = new InMemoryStorage(); + var repo = newRepo(storage); + + S88Element root = element("Root", S88Level.AREA); + root.addChild(element("A", S88Level.PROCESSCELL)); + root.addChild(element("B", S88Level.PROCESSCELL)); + root.addChild(element("C", S88Level.PROCESSCELL)); + repo.savePlant(model(root)); + + S88PlantModel loaded = repo.loadPlant(); + assertNotNull(loaded); + List children = loaded.getRoot().getChildren(); + assertEquals(3, children.size()); + assertEquals("A", children.get(0).getId()); + assertEquals("B", children.get(1).getId()); + assertEquals("C", children.get(2).getId()); + } + + // ========== Round-trip: Properties ========== + + @Test + void roundTripWithProperties() { + var storage = new InMemoryStorage(); + var repo = newRepo(storage); + + S88Element root = element("Root", S88Level.AREA); + root.setProperty("author", "John Doe"); + root.setProperty("icon", "custom.png"); + root.setProperty("version", "2.1"); + repo.savePlant(model(root)); + + S88PlantModel loaded = repo.loadPlant(); + assertNotNull(loaded); + S88Element loadedRoot = loaded.getRoot(); + assertEquals("John Doe", loadedRoot.getProperty("author")); + assertEquals("custom.png", loadedRoot.getProperty("icon")); + assertEquals("2.1", loadedRoot.getProperty("version")); + } + + @Test + void roundTripEmptyStringProperty() { + var storage = new InMemoryStorage(); + var repo = newRepo(storage); + + S88Element root = element("Root", S88Level.NULL); + root.setProperty("note", ""); + repo.savePlant(model(root)); + + S88PlantModel loaded = repo.loadPlant(); + assertNotNull(loaded); + assertEquals("", loaded.getRoot().getProperty("note")); + } + + @Test + void nullPropertyNotPersistedAsEntry() { + var storage = new InMemoryStorage(); + var repo = newRepo(storage); + + S88Element root = element("Root", S88Level.NULL); + root.setProperty("author", "StillHere"); + root.setProperty("icon", null); + repo.savePlant(model(root)); + + S88PlantModel loaded = repo.loadPlant(); + assertNotNull(loaded); + assertEquals("StillHere", loaded.getRoot().getProperty("author")); + assertEquals("", loaded.getRoot().getProperty("icon")); + } + + // ========== Description handling ========== + + @Test + void descriptionPersistedAsBothDedicatedElementAndProperty() throws XmlException, IOException { + var storage = new InMemoryStorage(); + var repo = newRepo(storage); + + S88Element root = element("Root", S88Level.AREA); + root.setProperty("description", "My description"); + repo.savePlant(model(root)); + + EquipmentInformationDocument doc = EquipmentInformationDocument.Factory.parse(new ByteArrayInputStream(storage.data)); + EquipmentInformationType info = doc.getEquipmentInformation(); + EquipmentType xml = info.getEquipmentArray(0); + + assertTrue(xml.sizeOfDescriptionArray() > 0); + assertEquals("My description", xml.getDescriptionArray(0).getStringValue()); + } + + // ========== Level round-trip ========== + + @Test + void levelMappingRoundTrip() { + var storage = new InMemoryStorage(); + var repo = newRepo(storage); + + for (S88Level level : S88Level.values()) { + storage.data = null; + repo.savePlant(model(element("Test", level))); + + S88PlantModel loaded = repo.loadPlant(); + assertNotNull(loaded, "Failed for level: " + level); + assertEquals(level, loaded.getRoot().getLevel(), + "Level mismatch for: " + level); + } + } + + // ========== Property key with special characters ========== + + @Test + void propertyKeyWithSpecialCharacters() { + var storage = new InMemoryStorage(); + var repo = newRepo(storage); + + S88Element root = element("Root", S88Level.AREA); + root.setProperty("my.custom-key_123", "value"); + repo.savePlant(model(root)); + + S88PlantModel loaded = repo.loadPlant(); + assertNotNull(loaded); + assertEquals("value", loaded.getRoot().getProperty("my.custom-key_123")); + } + + // ========== Id with special characters ========== + + @Test + void elementIdWithSpecialCharacters() { + var storage = new InMemoryStorage(); + var repo = newRepo(storage); + + S88Element root = element("Root-123_Area", S88Level.AREA); + repo.savePlant(model(root)); + + S88PlantModel loaded = repo.loadPlant(); + assertNotNull(loaded); + assertEquals("Root-123_Area", loaded.getRoot().getId()); + } + + // ========== Error Handling ========== + + @Test + void loadReturnsNullOnEmptyStorage() { + var storage = new InMemoryStorage(); + var repo = newRepo(storage); + storage.data = new byte[0]; + + assertNull(repo.loadPlant()); + } + + @Test + void loadReturnsNullOnInvalidXml() { + var storage = new InMemoryStorage(); + var repo = newRepo(storage); + storage.data = "not valid xml".getBytes(); + + assertNull(repo.loadPlant()); + } + + @Test + void loadReturnsNullOnPartiallyValidXml() { + var storage = new InMemoryStorage(); + var repo = newRepo(storage); + storage.data = "".getBytes(); + + assertNull(repo.loadPlant()); + } + + // ========== Direct XML parsing: verify saved structure ========== + + @Test + void savedXmlContainsExpectedIdsAndLevels() throws XmlException, IOException { + var storage = new InMemoryStorage(); + var repo = newRepo(storage); + + S88Element root = element("Top", S88Level.AREA); + root.addChild(element("Mid", S88Level.PROCESSCELL)); + repo.savePlant(model(root)); + + EquipmentInformationDocument doc = EquipmentInformationDocument.Factory.parse(new ByteArrayInputStream(storage.data)); + EquipmentInformationType info = doc.getEquipmentInformation(); + EquipmentType xml = info.getEquipmentArray(0); + + assertEquals("Top", xml.getID().getStringValue()); + assertEquals("Area", xml.getEquipmentLevel().getStringValue()); + + assertEquals(1, xml.sizeOfEquipmentChildArray()); + assertEquals("Mid", xml.getEquipmentChildArray(0).getID().getStringValue()); + assertEquals("ProcessCell", xml.getEquipmentChildArray(0).getEquipmentLevel().getStringValue()); + } + + @Test + void savedXmlContainsPropertyElements() throws XmlException, IOException { + var storage = new InMemoryStorage(); + var repo = newRepo(storage); + + S88Element root = element("Root", S88Level.NULL); + root.setProperty("author", "Jane"); + root.setProperty("icon", "icon.png"); + repo.savePlant(model(root)); + + EquipmentInformationDocument doc = EquipmentInformationDocument.Factory.parse(new ByteArrayInputStream(storage.data)); + EquipmentInformationType info = doc.getEquipmentInformation(); + EquipmentType xml = info.getEquipmentArray(0); + + assertEquals(2, xml.sizeOfEquipmentPropertyArray()); + + String id0 = xml.getEquipmentPropertyArray(0).getID().getStringValue(); + String id1 = xml.getEquipmentPropertyArray(1).getID().getStringValue(); + assertTrue((id0.equals("author") && id1.equals("icon")) + || (id0.equals("icon") && id1.equals("author"))); + } + + // ========== Save with suppressed level (NULL) ========== + + @Test + void savedXmlOmitsLevelForNullLevel() throws XmlException, IOException { + var storage = new InMemoryStorage(); + var repo = newRepo(storage); + + repo.savePlant(model(element("Root", S88Level.NULL))); + + EquipmentInformationDocument doc = EquipmentInformationDocument.Factory.parse(new ByteArrayInputStream(storage.data)); + EquipmentInformationType info = doc.getEquipmentInformation(); + EquipmentType xml = info.getEquipmentArray(0); + + assertNull(xml.getEquipmentLevel()); + } + + // ========== Multiple saves round-trip ========== + + @Test + void multipleSaveCycles() { + var storage = new InMemoryStorage(); + var repo = newRepo(storage); + + S88Element root = element("V1", S88Level.UNIT); + root.setProperty("x", "1"); + repo.savePlant(model(root)); + + S88PlantModel loaded = repo.loadPlant(); + assertNotNull(loaded); + assertEquals("V1", loaded.getRoot().getId()); + assertEquals("1", loaded.getRoot().getProperty("x")); + + loaded.getRoot().setId("V2"); + loaded.getRoot().setProperty("x", "2"); + repo.savePlant(loaded); + + S88PlantModel loaded2 = repo.loadPlant(); + assertNotNull(loaded2); + assertEquals("V2", loaded2.getRoot().getId()); + assertEquals("2", loaded2.getRoot().getProperty("x")); + } + + // ========== Round-trip: structured / nested properties ========== + + @Test + void roundTripNestedStructPropertySingleEntry() { + var storage = new InMemoryStorage(); + var repo = newRepo(storage); + + S88Element root = element("Unit1", S88Level.UNIT); + + Map bag = new LinkedHashMap<>(); + bag.put("Type", "REAL"); + bag.put("Eng_Units/Enum", "CEL"); + bag.put("Reference", 7); + bag.put("StaticValue", "1.5"); + + Map parameters = new LinkedHashMap<>(); + parameters.put("Temperature", bag); + root.setProperty("Parameters", parameters); + + repo.savePlant(model(root)); + + S88PlantModel loaded = repo.loadPlant(); + assertNotNull(loaded); + + Map loadedParameters = loaded.getRoot().getStructuredProperty("Parameters"); + assertNotNull(loadedParameters); + assertEquals(1, loadedParameters.size()); + + @SuppressWarnings("unchecked") + Map loadedBag = (Map) loadedParameters.get("Temperature"); + assertNotNull(loadedBag); + assertEquals("REAL", loadedBag.get("Type")); + assertEquals("CEL", loadedBag.get("Eng_Units/Enum")); + assertEquals("1.5", loadedBag.get("StaticValue")); + // Integers are serialized as "int" and read back as Long. + assertEquals(7, ((Number) loadedBag.get("Reference")).intValue()); + } + + @Test + void roundTripNestedStructPropertyMultipleEntries() { + var storage = new InMemoryStorage(); + var repo = newRepo(storage); + + S88Element root = element("Unit1", S88Level.UNIT); + + Map temperature = new LinkedHashMap<>(); + temperature.put("Type", "REAL"); + Map pressure = new LinkedHashMap<>(); + pressure.put("Type", "INTEGER"); + + Map parameters = new LinkedHashMap<>(); + parameters.put("Temperature", temperature); + parameters.put("Pressure", pressure); + root.setProperty("Parameters", parameters); + + repo.savePlant(model(root)); + + S88PlantModel loaded = repo.loadPlant(); + assertNotNull(loaded); + + Map loadedParameters = loaded.getRoot().getStructuredProperty("Parameters"); + assertNotNull(loadedParameters); + assertEquals(List.of("Temperature", "Pressure"), List.copyOf(loadedParameters.keySet())); + + @SuppressWarnings("unchecked") + Map loadedTemperature = (Map) loadedParameters.get("Temperature"); + @SuppressWarnings("unchecked") + Map loadedPressure = (Map) loadedParameters.get("Pressure"); + assertEquals("REAL", loadedTemperature.get("Type")); + assertEquals("INTEGER", loadedPressure.get("Type")); + } + + @Test + void roundTripUnitAttributeAsTopLevelMap() { + var storage = new InMemoryStorage(); + var repo = newRepo(storage); + + S88Element root = element("Unit1", S88Level.UNIT); + + Map attribute = new LinkedHashMap<>(); + attribute.put("Type", "REAL"); + attribute.put("Eng_Units/Enum", "CEL"); + attribute.put("StaticValue", "20.0"); + root.setProperty("Level", attribute); + + repo.savePlant(model(root)); + + S88PlantModel loaded = repo.loadPlant(); + assertNotNull(loaded); + + Map loadedAttribute = loaded.getRoot().getStructuredProperty("Level"); + assertNotNull(loadedAttribute); + assertEquals("REAL", loadedAttribute.get("Type")); + assertEquals("CEL", loadedAttribute.get("Eng_Units/Enum")); + assertEquals("20.0", loadedAttribute.get("StaticValue")); + } + + @Test + void roundTripDeeplyNestedStruct() { + var storage = new InMemoryStorage(); + var repo = newRepo(storage); + + S88Element root = element("Unit1", S88Level.UNIT); + + Map range = new LinkedHashMap<>(); + range.put("Unit", "CEL"); + + Map bag = new LinkedHashMap<>(); + bag.put("Type", "REAL"); + bag.put("Range", range); + + Map parameters = new LinkedHashMap<>(); + parameters.put("Temperature", bag); + root.setProperty("Parameters", parameters); + + repo.savePlant(model(root)); + + S88PlantModel loaded = repo.loadPlant(); + assertNotNull(loaded); + + @SuppressWarnings("unchecked") + Map loadedBag = (Map) loaded.getRoot() + .getStructuredProperty("Parameters").get("Temperature"); + assertNotNull(loadedBag); + assertEquals("REAL", loadedBag.get("Type")); + + @SuppressWarnings("unchecked") + Map loadedRange = (Map) loadedBag.get("Range"); + assertNotNull(loadedRange); + assertEquals("CEL", loadedRange.get("Unit")); + } + + @Test + void emptyStructPropertyIsDropped() { + var storage = new InMemoryStorage(); + var repo = newRepo(storage); + + S88Element root = element("Unit1", S88Level.UNIT); + root.setProperty("Parameters", new LinkedHashMap()); + + repo.savePlant(model(root)); + + S88PlantModel loaded = repo.loadPlant(); + assertNotNull(loaded); + assertNull(loaded.getRoot().getStructuredProperty("Parameters")); + assertEquals("", loaded.getRoot().getProperty("Parameters")); + } + + @Test + void roundTripEquipmentClassWithNestedProperties() { + var storage = new InMemoryStorage(); + var repo = newRepo(storage); + + S88PlantModel model = model(element("Plant", S88Level.NULL)); + + S88ElementClass sc = new S88ElementClass(); + sc.setName("MotorClass"); + sc.setTargetLevel(S88Level.EQUIPMENTMODULE); + + Map bag = new LinkedHashMap<>(); + bag.put("Type", "REAL"); + sc.setProperty("Speed", bag); + model.registerClass(sc); + + repo.savePlant(model); + + S88PlantModel loaded = repo.loadPlant(); + assertNotNull(loaded); + + S88ElementClass loadedClass = loaded.findClass("MotorClass"); + assertNotNull(loadedClass); + assertEquals(S88Level.EQUIPMENTMODULE, loadedClass.getTargetLevel()); + + @SuppressWarnings("unchecked") + Map loadedBag = (Map) loadedClass.getProperty("Speed"); + assertNotNull(loadedBag); + assertEquals("REAL", loadedBag.get("Type")); + } + + @Test + void savedXmlContainsNestedPropertyStructure() throws XmlException, IOException { + var storage = new InMemoryStorage(); + var repo = newRepo(storage); + + S88Element root = element("Unit1", S88Level.UNIT); + + Map bag = new LinkedHashMap<>(); + bag.put("Type", "REAL"); + Map parameters = new LinkedHashMap<>(); + parameters.put("Temperature", bag); + root.setProperty("Parameters", parameters); + + repo.savePlant(model(root)); + + EquipmentInformationDocument doc = EquipmentInformationDocument.Factory.parse(new ByteArrayInputStream(storage.data)); + EquipmentType xml = doc.getEquipmentInformation().getEquipmentArray(0); + + var parametersXml = xml.getEquipmentPropertyArray(0); + assertEquals("Parameters", parametersXml.getID().getStringValue()); + assertEquals(1, parametersXml.sizeOfEquipmentPropertyChildArray()); + + var temperatureXml = parametersXml.getEquipmentPropertyChildArray(0); + assertEquals("Temperature", temperatureXml.getID().getStringValue()); + assertEquals(1, temperatureXml.sizeOfEquipmentPropertyChildArray()); + assertEquals("Type", temperatureXml.getEquipmentPropertyChildArray(0).getID().getStringValue()); + } +} diff --git a/plc4j/tools/malbec/malbec-s88/nb-configuration.xml b/plc4j/tools/malbec/malbec-s88/nb-configuration.xml new file mode 100644 index 000000000..1abd1d461 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/nb-configuration.xml @@ -0,0 +1,21 @@ + + + + + + [{"repository":"php","rule":"S3335"},{"repository":"php","rule":"S3336"},{"repository":"php","rule":"S2002"},{"repository":"php","rule":"S2005"},{"repository":"php","rule":"S3333"},{"repository":"php","rule":"S3334"},{"repository":"Web","rule":"ImgWithoutWidthOrHeightCheck"},{"repository":"php","rule":"S1151"},{"repository":"php","rule":"S3332"},{"repository":"php","rule":"S2001"},{"repository":"Web","rule":"UnclosedTagCheck"},{"repository":"php","rule":"S2000"},{"repository":"java","rule":"S2309"},{"repository":"php","rule":"S126"},{"repository":"java","rule":"S4605"},{"repository":"java","rule":"S4604"},{"repository":"java","rule":"S2308"},{"repository":"xml","rule":"S3282"},{"repository":"php","rule":"S2918"},{"repository":"java","rule":"S2301"},{"repository":"java","rule":"S1696"},{"repository":"java","rule":"S1213"},{"repository":"java","rule":"S1698"},{"repository":"java","rule":"S1699"},{"repository":"php","rule":"S1820"},{"repository":"java","rule":"S3750"},{"repository":"java","rule":"S4288"},{"repository":"java","rule":"S1451"},{"repository":"php","rule":"S122"},{"repository":"java","rule":"S1694"},{"repository":"java","rule":"S1695"},{"repository":"php","rule":"S1821"},{"repository":"php","rule":"S5867"},{"repository":"java","rule":"S6211"},{"repository":"java","rule":"S2096"},{"repository":"java","rule":"S6212"},{"repository":"php","rule":"S2011"},{"repository":"Web","rule":"UnifiedExpressionCheck"},{"repository":"Web","rule":"PageWithoutFaviconCheck"},{"repository":"php","rule":"S134"},{"repository":"java","rule":"S1448"},{"repository":"java","rule":"S2658"},{"repository":"java","rule":"S1449"},{"repository":"java","rule":"S3749"},{"repository":"php","rule":"S139"},{"repository":"java","rule":"S5128"},{"repository":"php","rule":"S2007"},{"repository":"php","rule":"S3337"},{"repository":"php","rule":"S3338"},{"repository":"java","rule":"S1200"},{"repository":"Web","rule":"ComplexityCheck"},{"repository":"Web","rule":"RequiredAttributeCheck"},{"repository":"php","rule":"S104"},{"repository":"java","rule":"S3658"},{"repository":"java","rule":"S2208"},{"repository":"java","rule":"S2444"},{"repository":"java","rule":"S2203"},{"repository":"java","rule":"S2325"},{"repository":"java","rule":"S3414"},{"repository":"Web","rule":"S1829"},{"repository":"java","rule":"S4174"},{"repository":"Web","rule":"IllegalTabCheck"},{"repository":"java","rule":"S1106"},{"repository":"java","rule":"S1228"},{"repository":"java","rule":"S1107"},{"repository":"java","rule":"S1108"},{"repository":"java","rule":"S1109"},{"repository":"xml","rule":"S3373"},{"repository":"java","rule":"S1105"},{"repository":"php","rule":"S5856"},{"repository":"java","rule":"S2063"},{"repository":"java","rule":"S3030"},{"repository":"java","rule":"S3032"},{"repository":"Web","rule":"DoubleQuotesCheck"},{"repository":"Web","rule":"MaxLineLengthCheck"},{"repository":"java","rule":"S105"},{"repository":"Web","rule":"LongJavaScriptCheck"},{"repository":"java","rule":"S103"},{"repository":"Web","rule":"WmodeIsWindowCheck"},{"repository":"java","rule":"S104"},{"repository":"java","rule":"S109"},{"repository":"java","rule":"S113"},{"repository":"java","rule":"S4926"},{"repository":"java","rule":"S4248"},{"repository":"java","rule":"S1774"},{"repository":"php","rule":"S1105"},{"repository":"php","rule":"S1106"},{"repository":"php","rule":"S1121"},{"repository":"Web","rule":"InputWithoutLabelCheck"},{"repository":"xml","rule":"S2260"},{"repository":"java","rule":"S2059"},{"repository":"java","rule":"S1641"},{"repository":"java","rule":"S2972"},{"repository":"java","rule":"S2973"},{"repository":"java","rule":"S2974"},{"repository":"java","rule":"S2057"},{"repository":"java","rule":"S6411"},{"repository":"php","rule":"S1117"},{"repository":"java","rule":"S3052"},{"repository":"php","rule":"S1451"},{"repository":"Web","rule":"NonConsecutiveHeadingCheck"},{"repository":"java","rule":"S126"},{"repository":"java","rule":"S923"},{"repository":"java","rule":"S134"},{"repository":"java","rule":"S1315"},{"repository":"java","rule":"S1312"},{"repository":"java","rule":"S1314"},{"repository":"java","rule":"S4266"},{"repository":"java","rule":"S1310"},{"repository":"java","rule":"S2196"},{"repository":"java","rule":"S2197"},{"repository":"java","rule":"S118"},{"repository":"java","rule":"S1309"},{"repository":"java","rule":"S3725"},{"repository":"java","rule":"S121"},{"repository":"java","rule":"S122"},{"repository":"Web","rule":"DynamicJspIncludeCheck"},{"repository":"php","rule":"S1578"},{"repository":"php","rule":"S5935"},{"repository":"Web","rule":"InlineStyleCheck"},{"repository":"java","rule":"S3047"},{"repository":"java","rule":"S1541"},{"repository":"java","rule":"S2260"},{"repository":"java","rule":"S2141"},{"repository":"php","rule":"S1311"},{"repository":"java","rule":"S2384"},{"repository":"php","rule":"S4142"},{"repository":"xml","rule":"S105"},{"repository":"java","rule":"S2701"},{"repository":"xml","rule":"S103"},{"repository":"java","rule":"S2148"},{"repository":"php","rule":"S881"},{"repository":"java","rule":"S2143"},{"repository":"java","rule":"S1176"},{"repository":"java","rule":"S1160"},{"repository":"php","rule":"S1200"},{"repository":"java","rule":"S2250"},{"repository":"java","rule":"S818"},{"repository":"java","rule":"S1162"},{"repository":"java","rule":"S4551"},{"repository":"java","rule":"S2131"},{"repository":"java","rule":"S138"},{"repository":"java","rule":"S139"},{"repository":"Web","rule":"IllegalNamespaceCheck"},{"repository":"php","rule":"S5915"},{"repository":"php","rule":"S1314"},{"repository":"java","rule":"S1166"},{"repository":"php","rule":"S1799"},{"repository":"java","rule":"S5793"},{"repository":"java","rule":"S1194"},{"repository":"java","rule":"S2162"},{"repository":"java","rule":"S2164"},{"repository":"xml","rule":"S3419"},{"repository":"Web","rule":"WhiteSpaceAroundCheck"},{"repository":"java","rule":"S3937"},{"repository":"xml","rule":"S1120"},{"repository":"java","rule":"S1996"},{"repository":"Web","rule":"MultiplePageDirectivesCheck"},{"repository":"xml","rule":"S3420"},{"repository":"java","rule":"S3254"},{"repository":"xml","rule":"S3423"},{"repository":"java","rule":"S2047"},{"repository":"php","rule":"S1541"},{"repository":"Web","rule":"InternationalizationCheck"},{"repository":"java","rule":"S3242"},{"repository":"java","rule":"S6073"},{"repository":"php","rule":"S2070"},{"repository":"java","rule":"S2959"},{"repository":"Web","rule":"LinkToNothingCheck"},{"repository":"Web","rule":"S1436"},{"repository":"java","rule":"S2039"},{"repository":"xml","rule":"S2321"},{"repository":"java","rule":"S1188"},{"repository":"java","rule":"S1067"},{"repository":"java","rule":"S2156"},{"repository":"java","rule":"S3366"},{"repository":"Web","rule":"LinksIdenticalTextsDifferentTargetsCheck"},{"repository":"php","rule":"S2047"},{"repository":"php","rule":"S2046"},{"repository":"java","rule":"S5970"},{"repository":"php","rule":"S2043"},{"repository":"php","rule":"S2042"},{"repository":"php","rule":"S1990"},{"repository":"php","rule":"S2044"},{"repository":"java","rule":"S864"},{"repository":"Web","rule":"MouseEventWithoutKeyboardEquivalentCheck"},{"repository":"java","rule":"S5979"},{"repository":"java","rule":"NoSonar"},{"repository":"java","rule":"S5977"},{"repository":"java","rule":"S5612"},{"repository":"java","rule":"S1258"},{"repository":"java","rule":"S3437"},{"repository":"Web","rule":"FileLengthCheck"},{"repository":"Web","rule":"JspScriptletCheck"},{"repository":"java","rule":"S2221"},{"repository":"java","rule":"S1132"},{"repository":"java","rule":"S3553"},{"repository":"php","rule":"NoSonar"},{"repository":"Web","rule":"IllegalTagLibsCheck"},{"repository":"php","rule":"S2050"},{"repository":"java","rule":"S3306"},{"repository":"java","rule":"S2698"},{"repository":"php","rule":"S1996"},{"repository":"php","rule":"S2964"},{"repository":"java","rule":"S2693"},{"repository":"java","rule":"S1120"},{"repository":"java","rule":"S2694"},{"repository":"java","rule":"S2211"},{"repository":"php","rule":"S1997"},{"repository":"java","rule":"S2333"},{"repository":"java","rule":"S1244"},{"repository":"php","rule":"S5899"},{"repository":"java","rule":"S1151"},{"repository":"Web","rule":"IllegalElementCheck"},{"repository":"java","rule":"S5194"},{"repository":"php","rule":"S2260"},{"repository":"java","rule":"S888"},{"repository":"java","rule":"S1711"},{"repository":"java","rule":"S3578"},{"repository":"php","rule":"S2036"},{"repository":"php","rule":"S2278"},{"repository":"php","rule":"S2277"},{"repository":"php","rule":"S1067"},{"repository":"php","rule":"S2830"},{"repository":"php","rule":"S2038"},{"repository":"php","rule":"S2037"},{"repository":"php","rule":"S5783"},{"repository":"java","rule":"S1939"},{"repository":"java","rule":"S1821"},{"repository":"java","rule":"S1942"},{"repository":"java","rule":"S1943"},{"repository":"java","rule":"S881"},{"repository":"java","rule":"S5867"},{"repository":"java","rule":"S1147"},{"repository":"java","rule":"S1820"},{"repository":"java","rule":"S1941"},{"repository":"java","rule":"S1142"},{"repository":"php","rule":"S2701"},{"repository":"Web","rule":"HeaderCheck"}] + global + {} + true + + diff --git a/plc4j/tools/malbec/malbec-s88/plant/pom.xml b/plc4j/tools/malbec/malbec-s88/plant/pom.xml new file mode 100644 index 000000000..c0ebef20e --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/pom.xml @@ -0,0 +1,169 @@ + + + + 4.0.0 + + org.apache.plc4x.malbec.s88 + s88 + 1.1.0-SNAPSHOT + + + plant + ${project.groupId}:${project.artifactId} + nbm + + + + + org.apache.netbeans.utilities + nbm-maven-plugin + true + + + org.apache.plc4x.malbec.s88.plant.impl + org.apache.plc4x.malbec.s88.plant.actions + org.apache.plc4x.malbec.s88.plant.panels + org.apache.plc4x.malbec.s88.plant.services + + + + + org.apache.maven.plugins + maven-jar-plugin + + + ${project.build.outputDirectory}/META-INF/MANIFEST.MF + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.1 + + false + full + + + + + + + org.netbeans.api + org-netbeans-api-annotations-common + ${netbeans.version} + + + org.netbeans.api + org-openide-loaders + ${netbeans.version} + + + org.netbeans.api + org-openide-dialogs + ${netbeans.version} + + + org.netbeans.api + org-openide-filesystems + ${netbeans.version} + + + org.netbeans.api + org-openide-util-lookup + ${netbeans.version} + + + org.netbeans.api + org-openide-nodes + ${netbeans.version} + + + org.netbeans.api + org-netbeans-modules-projectapi + ${netbeans.version} + + + org.netbeans.api + org-netbeans-modules-projectuiapi + ${netbeans.version} + + + org.netbeans.api + org-openide-util-ui + ${netbeans.version} + + + org.netbeans.api + org-openide-util + ${netbeans.version} + + + org.netbeans.api + org-netbeans-api-templates + ${netbeans.version} + + + org.netbeans.api + org-openide-windows + ${netbeans.version} + + + org.netbeans.api + org-openide-text + ${netbeans.version} + + + org.netbeans.api + org-netbeans-core-multiview + ${netbeans.version} + + + org.netbeans.api + org-openide-actions + ${netbeans.version} + + + org.netbeans.api + org-openide-awt + ${netbeans.version} + + + org.apache.plc4x.malbec.s88 + api + ${project.version} + + + org.apache.plc4x.malbec.s88 + core + ${project.version} + + + org.apache.plc4x.malbec.core + libs + ${project.version} + + + org.netbeans.api + org-netbeans-modules-settings + ${netbeans.version} + + + diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/actions/CreatePlantElementAction.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/actions/CreatePlantElementAction.java new file mode 100644 index 000000000..dc5b1064c --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/actions/CreatePlantElementAction.java @@ -0,0 +1,107 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.malbec.s88.plant.actions; + +import java.awt.event.ActionEvent; +import java.util.List; +import javax.swing.AbstractAction; +import javax.swing.Action; +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.api.S88ElementClass; +import org.apache.plc4x.malbec.s88.plant.impl.Plc4xPlantModel; +import org.apache.plc4x.malbec.s88.plant.panels.NewElementDialog; +import org.netbeans.api.project.Project; +import org.openide.DialogDisplayer; +import org.openide.NotifyDescriptor; +import org.openide.nodes.Node; +import org.openide.util.ContextAwareAction; +import org.openide.util.Lookup; +import org.openide.util.NbBundle.Messages; + +/** + * Action to create a new ISA-88 Plant Element. + */ +public class CreatePlantElementAction extends AbstractAction implements ContextAwareAction { + + private final Lookup context; + + public CreatePlantElementAction() { + this(Lookup.EMPTY); + } + + private CreatePlantElementAction(Lookup context) { + super(Bundle.BTN_create_plant_element()); + this.context = context; + } + + @Messages({ + "BTN_create_plant_element=New Element", + "LBL_CreatePlantElement=Create Element", + "LBL_ElementID=Element ID:", + "# {0} - element id", + "ERR_DuplicateID=Element ID ''{0}'' already exists.", + "ERR_EmptyID=Element ID cannot be empty." + }) + @Override + public void actionPerformed(ActionEvent e) { + Project project = context.lookup(Project.class); + if (project == null) { + Node node = context.lookup(Node.class); + if (node != null) { + project = node.getLookup().lookup(Project.class); + } + } + if (project == null) return; + + Plc4xPlantModel plantModel = project.getLookup().lookup(Plc4xPlantModel.class); + if (plantModel == null) return; + + + S88Element parentEq = context.lookup(S88Element.class); + if (parentEq == null) { + if (plantModel.getModel() == null) return; + parentEq = plantModel.getModel().getRoot(); + } + + + List definedClasses = parentEq.getElementClasses(); + + if (parentEq.getLevel() == null || parentEq.getLevel().getChildLevel() == null) { + DialogDisplayer.getDefault().notify(new NotifyDescriptor.Message( + "Cannot create an element under an Equipment Module (leaf level).", + NotifyDescriptor.INFORMATION_MESSAGE)); + return; + } + + NewElementDialog newElementDialog = new NewElementDialog(plantModel, parentEq, definedClasses); + newElementDialog.setVisible(true); + + + + + + } + + + + @Override + public Action createContextAwareInstance(Lookup lkp) { + return new CreatePlantElementAction(lkp); + } +} diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/actions/CreatePlantProjectAction.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/actions/CreatePlantProjectAction.java new file mode 100644 index 000000000..27fb9ccc1 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/actions/CreatePlantProjectAction.java @@ -0,0 +1,124 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.malbec.s88.plant.actions; + +import java.awt.event.ActionEvent; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import javax.swing.AbstractAction; +import javax.swing.Action; + +import org.apache.plc4x.malbec.s88.api.*; +import org.apache.plc4x.malbec.s88.plant.impl.Plc4xPlantSubProjectProviderImpl; +import org.apache.plc4x.malbec.s88.plant.services.S88ProjectServices; +import org.netbeans.api.project.Project; +import org.openide.DialogDisplayer; +import org.openide.NotifyDescriptor; +import org.openide.awt.ActionID; +import org.openide.awt.ActionReference; +import org.openide.awt.ActionRegistration; +import org.openide.filesystems.FileObject; +import org.openide.util.ContextAwareAction; +import org.openide.util.Exceptions; +import org.openide.util.Lookup; +import org.openide.util.NbBundle.Messages; + +/** + * Action to create a new Plant Sub-project. + */ +@ActionID(category = "Project", id = "org.apache.plc4x.malbec.s88.plant.actions.CreatePlantProjectAction") +@ActionRegistration(displayName = "#CTL_CreatePlantProjectAction", lazy = false) +@ActionReference(path = "Projects/org-plc4x-s88-project/Actions", position = 150) +@Messages({ + "CTL_CreatePlantProjectAction=New Plant", + "LBL_CreatePlantProject=Create Plant Sub-project", + "LBL_ProjectName=Project Name:" +}) +public class CreatePlantProjectAction extends AbstractAction implements ContextAwareAction { + + private final Lookup context; + + public CreatePlantProjectAction() { + this(Lookup.EMPTY); + } + + private CreatePlantProjectAction(Lookup context) { + super(Bundle.CTL_CreatePlantProjectAction()); + this.context = context; + } + + @Override + public void actionPerformed(ActionEvent e) { + Project project = context.lookup(Project.class); + if (project == null) { + return; + } + + NotifyDescriptor.InputLine input = new NotifyDescriptor.InputLine(Bundle.LBL_ProjectName(), Bundle.LBL_CreatePlantProject()); + input.setInputText(""); + if (DialogDisplayer.getDefault().notify(input) != NotifyDescriptor.OK_OPTION) { + return; + } + String name = input.getInputText(); + + try { + FileObject dir = project.getProjectDirectory().createFolder(name); + FileObject plantXml = dir.createData("plant.xml"); + + S88PlantModel model = new S88PlantModel(new S88Element()); + model.getRoot().setId(name); + + model.getRoot().setLevel(S88Level.AREA); + + S88Repository repo = S88ProjectServices.createRepository("xml", new FileObjectStorage(plantXml)); + repo.savePlant(model); + + project.getProjectDirectory().refresh(); + dir.refresh(); + + org.netbeans.api.project.ProjectManager.getDefault().findProject(dir); + + Plc4xPlantSubProjectProviderImpl provider = project.getLookup().lookup(Plc4xPlantSubProjectProviderImpl.class); + if (provider != null) { + java.awt.EventQueue.invokeLater(provider::fireChange); + } + } catch (IOException ex) { + Exceptions.printStackTrace(ex); + } + + } + + @Override + public Action createContextAwareInstance(Lookup lkp) { + return new CreatePlantProjectAction(lkp); + } + + private record FileObjectStorage(FileObject fo) implements S88Storage { + @Override + public InputStream openInput() throws IOException { + return fo.getInputStream(); + } + + @Override + public OutputStream openOutput() throws IOException { + return fo.getOutputStream(); + } + } +} diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/actions/CreateTemplateAction.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/actions/CreateTemplateAction.java new file mode 100644 index 000000000..bc1c4be50 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/actions/CreateTemplateAction.java @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.plc4x.malbec.s88.plant.actions; + +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.plant.impl.Plc4xPlantModel; +import org.apache.plc4x.malbec.s88.plant.panels.TemplateFactory; +import org.netbeans.api.project.Project; +import org.openide.util.ContextAwareAction; +import org.openide.util.Lookup; +import org.openide.util.NbBundle; + +import javax.swing.*; +import java.awt.event.ActionEvent; + +public class CreateTemplateAction extends AbstractAction implements ContextAwareAction { + private final Lookup context; + + public CreateTemplateAction() { + this(Lookup.EMPTY); + } + + private CreateTemplateAction(Lookup context) { + super(Bundle.BTN_Template()); + this.context = context; + } + + @Override + public Action createContextAwareInstance(Lookup actionContext) { + return new CreateTemplateAction(actionContext); + } + + @NbBundle.Messages({ + "BTN_Template=New Template" + }) + @Override + public void actionPerformed(ActionEvent e) { + Project project = context.lookup(Project.class); + S88Element parent = context.lookup(S88Element.class); + + if (project == null || parent == null) return; + Plc4xPlantModel plantModel = project.getLookup().lookup(Plc4xPlantModel.class); + if (plantModel == null || plantModel.getModel() == null) return; + + TemplateFactory.createDialog(parent, plantModel); + } +} diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/actions/DeletePlantElementAction.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/actions/DeletePlantElementAction.java new file mode 100644 index 000000000..72c8563fa --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/actions/DeletePlantElementAction.java @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.malbec.s88.plant.actions; + +import java.awt.event.ActionEvent; +import javax.swing.AbstractAction; +import javax.swing.Action; +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.core.DeleteElementUseCase; +import org.apache.plc4x.malbec.s88.plant.impl.Plc4xPlantModel; +import org.netbeans.api.project.Project; +import org.openide.DialogDisplayer; +import org.openide.NotifyDescriptor; +import org.openide.awt.ActionID; +import org.openide.awt.ActionReference; +import org.openide.awt.ActionRegistration; +import org.openide.util.ContextAwareAction; +import org.openide.util.Lookup; +import org.openide.util.NbBundle.Messages; + +/** + * Action to delete an ISA-88 Plant Element. + */ +@ActionID(category = "Project", id = "org.apache.plc4x.malbec.s88.plant.actions.DeletePlantElementAction") +@ActionRegistration(displayName = "#CTL_DeletePlantElementAction", lazy = false) +@ActionReference(path = "Projects/org-plc4x-plant-element/Actions", position = 200) +@Messages({ + "CTL_DeletePlantElementAction=Delete Plant Element", + "# {0} - Element name", + "MSG_ConfirmDelete=Are you sure you want to delete element ''{0}'' and all its children?" +}) +public class DeletePlantElementAction extends AbstractAction implements ContextAwareAction { + + private final Lookup context; + + public DeletePlantElementAction() { + this(Lookup.EMPTY); + } + + private DeletePlantElementAction(Lookup context) { + super(Bundle.CTL_DeletePlantElementAction()); + this.context = context; + } + + @Override + public void actionPerformed(ActionEvent e) { + Project project = context.lookup(Project.class); + S88Element targetEq = context.lookup(S88Element.class); + + if (project == null || targetEq == null) return; + Plc4xPlantModel plantModel = project.getLookup().lookup(Plc4xPlantModel.class); + if (plantModel == null || plantModel.getModel() == null) return; + + String id = targetEq.getId(); + NotifyDescriptor.Confirmation confirm = new NotifyDescriptor.Confirmation( + Bundle.MSG_ConfirmDelete(id), + Bundle.CTL_DeletePlantElementAction(), + NotifyDescriptor.YES_NO_OPTION); + + if (DialogDisplayer.getDefault().notify(confirm) != NotifyDescriptor.YES_OPTION) return; + + try { + DeleteElementUseCase.execute(plantModel.getModel(), targetEq); + plantModel.save(); + } catch (Exception ex) { + org.openide.util.Exceptions.printStackTrace(ex); + } + } + + @Override + public Action createContextAwareInstance(Lookup lkp) { + return new DeletePlantElementAction(lkp); + } +} diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/actions/ExportAction.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/actions/ExportAction.java new file mode 100644 index 000000000..1e9702af7 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/actions/ExportAction.java @@ -0,0 +1,76 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.plc4x.malbec.s88.plant.actions; + +import org.apache.plc4x.malbec.s88.plant.panels.ExportDialog; +import org.netbeans.spi.project.SubprojectProvider; +import org.openide.awt.ActionID; +import org.openide.awt.ActionReference; +import org.openide.awt.ActionRegistration; +import org.openide.util.ContextAwareAction; +import org.openide.util.Lookup; +import org.openide.util.NbBundle; +import org.netbeans.api.project.Project; + +import javax.swing.*; +import java.awt.*; +import java.awt.event.ActionEvent; +import java.util.Set; + +/** + * Action to export a plant model to another environment + */ +@ActionID(category = "Project", id = "org.apache.plc4x.malbec.s88.plant.actions.ExportAction") +@ActionRegistration(displayName = "#CTL_ExportAction", lazy = false) +@ActionReference(path = "Projects/org-plc4x-s88-project/Actions", position = 200) +@NbBundle.Messages({ + "CTL_ExportAction=Export" +}) +public class ExportAction extends AbstractAction implements ContextAwareAction { + + private final Lookup context; + + public ExportAction() { + this(Lookup.EMPTY); + } + + private ExportAction(Lookup context) { + super(Bundle.CTL_ExportAction()); + this.context = context; + } + + @Override + public Action createContextAwareInstance(Lookup actionContext) { + return new ExportAction(actionContext); + } + + @Override + public void actionPerformed(ActionEvent e) { + Project project = context.lookup(Project.class); + if (project == null) return; + SubprojectProvider provider = project.getLookup().lookup(SubprojectProvider.class); + if (provider == null) return; + Set projectList = provider.getSubprojects(); + if (projectList.isEmpty()) return; + Project[] projects = projectList.toArray(new Project[0]); + ExportDialog dialog = new ExportDialog(projects); + dialog.setVisible(true); + } +} diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/actions/ImportAction.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/actions/ImportAction.java new file mode 100644 index 000000000..152794be2 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/actions/ImportAction.java @@ -0,0 +1,158 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.plc4x.malbec.s88.plant.actions; + + +import org.apache.commons.io.FilenameUtils; +import org.apache.plc4x.malbec.s88.api.S88PlantModel; +import org.apache.plc4x.malbec.s88.api.S88Repository; +import org.apache.plc4x.malbec.s88.api.S88Storage; +import org.apache.plc4x.malbec.s88.plant.impl.Plc4xPlantSubProjectProviderImpl; +import org.apache.plc4x.malbec.s88.plant.services.S88ProjectServices; +import org.netbeans.api.project.Project; +import org.openide.awt.ActionID; +import org.openide.awt.ActionReference; +import org.openide.awt.ActionRegistration; +import org.openide.filesystems.FileObject; +import org.openide.util.ContextAwareAction; +import org.openide.util.Lookup; +import org.openide.util.NbBundle; + +import javax.swing.*; +import java.awt.*; +import java.awt.event.ActionEvent; +import java.io.*; + +/** + * Action to import a model from another environment + */ +@ActionID(category = "Project", id = "org.apache.plc4x.malbec.s88.plant.actions.ImportAction") +@ActionRegistration(displayName = "#CTL_ImportAction", lazy = false) +@ActionReference(path = "Projects/org-plc4x-s88-project/Actions", position = 250) +@NbBundle.Messages({ + "CTL_ImportAction=Import" +}) +public class ImportAction extends AbstractAction implements ContextAwareAction { + + private final Lookup context; + + public ImportAction() { + this(Lookup.EMPTY); + } + + private ImportAction(Lookup context) { + super(Bundle.CTL_ImportAction()); + this.context = context; + } + + @Override + public Action createContextAwareInstance(Lookup actionContext) { + return new ImportAction(actionContext); + } + + @Override + public void actionPerformed(ActionEvent e) { + Project project = context.lookup(Project.class); + if (project == null) return; + + File file = null; + + FileDialog chooser = new FileDialog((Frame) null, "Select File", FileDialog.LOAD); + + + chooser.setDirectory(System.getProperty("user.home")); + chooser.setVisible(true); + + String dir = chooser.getDirectory(); + String filename = chooser.getFile(); + + if (dir != null && filename != null){ + file = new File(dir, filename); + } + + if (file == null) return; + + try { + + S88Repository importer = S88ProjectServices.createRepository(FilenameUtils.getExtension(file.getName()), new FileStorage(file)); + S88PlantModel model = importer.loadPlant(); + if (model == null) { + JOptionPane.showMessageDialog(null, + "The selected file could not be loaded as a plant model.", + "Import Error", + JOptionPane.ERROR_MESSAGE); + return; + } + + String folderName = FilenameUtils.getBaseName(file.getName()); + + FileObject projectDir = project.getProjectDirectory(); + FileObject existingFolder = projectDir.getFileObject(folderName); + + if (existingFolder != null && existingFolder.isFolder()) { + + JOptionPane.showMessageDialog(null, + "There is already a '" + folderName + "' project", + "Duplicate Project", + JOptionPane.WARNING_MESSAGE); + return; + } + + FileObject folder = projectDir.createFolder(FilenameUtils.getBaseName(file.getName())); + FileObject plantXML = folder.createData("plant.xml"); + S88Repository saver = S88ProjectServices.createRepository("xml", new FileObjectStorage(plantXML)); + saver.savePlant(model); + + Plc4xPlantSubProjectProviderImpl provider = project.getLookup().lookup(Plc4xPlantSubProjectProviderImpl.class); + if (provider != null) { + java.awt.EventQueue.invokeLater(provider::fireChange); + } + } catch (IllegalArgumentException ia){ + JOptionPane.showMessageDialog(null, ia.getMessage(), "Error", JOptionPane.ERROR_MESSAGE); + } catch (IOException ex) { + throw new RuntimeException(ex); + } + + } + + private record FileStorage(File file) implements S88Storage { + @Override + public InputStream openInput() throws IOException { + return new FileInputStream(file); + } + + @Override + public OutputStream openOutput() throws IOException { + return new FileOutputStream(file); + } + } + + private record FileObjectStorage(FileObject fo) implements S88Storage { + @Override + public InputStream openInput() throws IOException { + return fo.getInputStream(); + } + + @Override + public OutputStream openOutput() throws IOException { + return fo.getOutputStream(); + } + } +} diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/actions/ManageEnumerationsAction.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/actions/ManageEnumerationsAction.java new file mode 100644 index 000000000..b5617517f --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/actions/ManageEnumerationsAction.java @@ -0,0 +1,202 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.plc4x.malbec.s88.plant.actions; + +import org.apache.plc4x.malbec.s88.api.S88Enumeration; +import org.apache.plc4x.malbec.s88.plant.impl.Plc4xPlantModel; +import org.apache.plc4x.malbec.s88.plant.panels.EnumerationDialogBuilder; +import org.netbeans.api.project.Project; +import org.openide.awt.ActionID; +import org.openide.awt.ActionReference; +import org.openide.awt.ActionRegistration; +import org.openide.util.ContextAwareAction; +import org.openide.util.Lookup; +import org.openide.util.NbBundle; + +import javax.swing.*; +import java.awt.*; +import java.awt.event.ActionEvent; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +@ActionID(category = "Project", id = "org.apache.plc4x.malbec.s88.plant.actions.ManageEnumerationsAction") +@ActionRegistration(displayName = "#CTL_ManageEnumerationsAction", lazy = false) +@ActionReference(path = "Projects/org-plc4x-plant-project/Actions", position = 160) +@NbBundle.Messages({ + "CTL_ManageEnumerationsAction=Enumerations", +}) +public class ManageEnumerationsAction extends AbstractAction implements ContextAwareAction { + + private final Lookup context; + + public ManageEnumerationsAction() { + this(Lookup.EMPTY); + } + + private ManageEnumerationsAction(Lookup context) { + super(Bundle.CTL_ManageEnumerationsAction()); + this.context = context; + } + + @Override + public Action createContextAwareInstance(Lookup actionContext) { + return new ManageEnumerationsAction(actionContext); + } + + @Override + public void actionPerformed(ActionEvent e) { + Project project = context.lookup(Project.class); + if (project == null) { + return; + } + Plc4xPlantModel plantModel = project.getLookup().lookup(Plc4xPlantModel.class); + if (plantModel == null || plantModel.getModel() == null) { + return; + } + new EnumerationManager(plantModel).show(); + } + + private static class EnumerationManager { + private final Plc4xPlantModel plantModel; + private final DefaultListModel listModel = new DefaultListModel<>(); + + EnumerationManager(Plc4xPlantModel plantModel) { + this.plantModel = plantModel; + for (S88Enumeration enumeration : plantModel.getModel().getEnumerations()) { + listModel.addElement(enumeration); + } + } + + void show() { + JDialog dialog = new JDialog(); + dialog.setTitle("Global Enumerations"); + dialog.setModal(true); + dialog.setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE); + + JList list = new JList<>(listModel); + list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + list.setCellRenderer((JList l, S88Enumeration value, + int index, boolean isSelected, boolean cellHasFocus) -> { + JLabel label = new JLabel(value.getName()); + label.setOpaque(true); + label.setBackground(isSelected ? l.getSelectionBackground() : l.getBackground()); + label.setForeground(isSelected ? l.getSelectionForeground() : l.getForeground()); + return label; + }); + + JPanel btnPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 5, 5)); + JButton btnNew = new JButton("New..."); + JButton btnEdit = new JButton("Edit..."); + JButton btnDelete = new JButton("Delete"); + JButton btnClose = new JButton("Close"); + + btnNew.addActionListener(ev -> { + new EnumerationDialogBuilder("New Enumeration Set") + .onSave((name, values) -> { + if (plantModel.getModel().findEnumeration(name) != null) { + JOptionPane.showMessageDialog(dialog, + "An enumeration set named '" + name + "' already exists.", + "Error", JOptionPane.ERROR_MESSAGE); + return; + } + S88Enumeration enumeration = new S88Enumeration(name); + enumeration.getValues().putAll(values); + plantModel.getModel().registerEnumeration(enumeration); + save(plantModel); + listModel.addElement(enumeration); + }) + .show(); + }); + + btnEdit.addActionListener(ev -> { + S88Enumeration selected = list.getSelectedValue(); + if (selected == null) { + JOptionPane.showMessageDialog(dialog, "Select an enumeration set to edit.", + "Info", JOptionPane.INFORMATION_MESSAGE); + return; + } + String name = selected.getName(); + S88Enumeration fresh = plantModel.getModel().findEnumeration(name); + Map current = fresh != null + ? new LinkedHashMap<>(fresh.getValues()) + : new LinkedHashMap<>(); + new EnumerationDialogBuilder("Edit Enumeration Set: " + name) + .withInitialData(name, current) + .onSave((n, values) -> { + if (!n.equals(name) && plantModel.getModel().findEnumeration(n) != null) { + JOptionPane.showMessageDialog(dialog, + "An enumeration set named '" + n + "' already exists.", + "Error", JOptionPane.ERROR_MESSAGE); + return; + } + plantModel.getModel().unregisterEnumeration(name); + S88Enumeration updated = new S88Enumeration(n); + updated.getValues().putAll(values); + plantModel.getModel().registerEnumeration(updated); + save(plantModel); + listModel.setElementAt(new S88Enumeration(n), listModel.indexOf(selected)); + }) + .show(); + }); + + btnDelete.addActionListener(ev -> { + S88Enumeration selected = list.getSelectedValue(); + if (selected == null) { + JOptionPane.showMessageDialog(dialog, "Select an enumeration set to delete.", + "Info", JOptionPane.INFORMATION_MESSAGE); + return; + } + plantModel.getModel().unregisterEnumeration(selected.getName()); + save(plantModel); + listModel.removeElement(selected); + }); + + btnClose.addActionListener(ev -> dialog.dispose()); + + btnPanel.add(btnNew); + btnPanel.add(btnEdit); + btnPanel.add(btnDelete); + btnPanel.add(btnClose); + + JScrollPane scroll = new JScrollPane(list); + scroll.setPreferredSize(new Dimension(320, 280)); + + JPanel content = new JPanel(new BorderLayout(10, 10)); + content.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); + content.add(scroll, BorderLayout.CENTER); + content.add(btnPanel, BorderLayout.SOUTH); + + dialog.setContentPane(content); + dialog.pack(); + dialog.setLocationRelativeTo(null); + dialog.setVisible(true); + } + + private static void save(Plc4xPlantModel plantModel) { + try { + plantModel.save(); + } catch (IOException ex) { + JOptionPane.showMessageDialog(null, "Failed to save: " + ex.getMessage(), + "Error", JOptionPane.ERROR_MESSAGE); + } + } + } +} diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/actions/PropertiesAction.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/actions/PropertiesAction.java new file mode 100644 index 000000000..0bf5de5d8 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/actions/PropertiesAction.java @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.plc4x.malbec.s88.plant.actions; + +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.plant.impl.Plc4xPlantModel; +import org.apache.plc4x.malbec.s88.plant.panels.PropertiesFactory; +import org.netbeans.api.project.Project; +import org.openide.util.ContextAwareAction; +import org.openide.util.Lookup; +import org.openide.util.NbBundle; + +import javax.swing.*; +import java.awt.event.ActionEvent; + +public class PropertiesAction extends AbstractAction implements ContextAwareAction { + private final Lookup context; + public PropertiesAction() { + this(Lookup.EMPTY); + } + + private PropertiesAction(Lookup context) { + super(Bundle.BTN_Props()); + this.context = context; + } + + + @Override + public Action createContextAwareInstance(Lookup actionContext) { + return new PropertiesAction(actionContext); + } + @NbBundle.Messages({ + "BTN_Props=Properties" + }) + @Override + public void actionPerformed(ActionEvent e) { + Project project = context.lookup(Project.class); + S88Element targetEq = context.lookup(S88Element.class); + + if (project == null || targetEq == null) return; + Plc4xPlantModel plantModel = project.getLookup().lookup(Plc4xPlantModel.class); + if (plantModel == null || plantModel.getModel() == null) return; + + + + JDialog props = PropertiesFactory.createDialog(targetEq, plantModel); + props.setVisible(true); + } +} diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/actions/ViewTemplatesAction.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/actions/ViewTemplatesAction.java new file mode 100644 index 000000000..3ce5d93ad --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/actions/ViewTemplatesAction.java @@ -0,0 +1,107 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.plc4x.malbec.s88.plant.actions; + +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.api.S88ElementClass; +import org.apache.plc4x.malbec.s88.plant.panels.TemplateDialogBuilder; +import org.apache.plc4x.malbec.s88.plant.panels.TemplateFactory; +import org.openide.util.ContextAwareAction; +import org.openide.util.Lookup; +import org.openide.util.NbBundle; +import javax.swing.*; +import java.awt.*; +import java.awt.event.ActionEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; + + +@NbBundle.Messages({ + "CTL_ViewTemplatesAction=View templates", +}) +public class ViewTemplatesAction extends AbstractAction implements ContextAwareAction { + + private final Lookup context; + + public ViewTemplatesAction() { + this(Lookup.EMPTY); + } + + private ViewTemplatesAction(Lookup context) { + super(Bundle.CTL_ViewTemplatesAction()); + this.context = context; + } + + @Override + public Action createContextAwareInstance(Lookup actionContext) { + return new ViewTemplatesAction(actionContext); + } + + @Override + public void actionPerformed(ActionEvent e) { + S88Element equipment = context.lookup(S88Element.class); + + DefaultListModel classListModel = new DefaultListModel<>(); + + for(S88ElementClass ec : equipment.getElementClasses()){ + classListModel.addElement(ec); + } + + JList list = new JList<>(classListModel); + + TemplateDialogBuilder builder = new TemplateDialogBuilder("Select Template", false); + + builder.withReadOnlyNameField(equipment.getId()); + list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + list.setCellRenderer(new DefaultListCellRenderer(){ + @Override + public Component getListCellRendererComponent(JList list, Object value, + int index, boolean isSelected, boolean cellHasFocus) { + super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + if (value instanceof S88ElementClass) { + setText(((S88ElementClass) value).getName()); + } + return this; + } + }); + + list.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getClickCount() == 2) { + int index = list.locationToIndex(e.getPoint()); + if (index >= 0 && list.getCellBounds(index, index).contains(e.getPoint())) { + S88ElementClass ec = list.getModel().getElementAt(index); + TemplateFactory.showTemplate(ec); + } + } + } + }); + + list.setVisibleRowCount(10); + JScrollPane listScrollPane = new JScrollPane(list); + listScrollPane.setPreferredSize(new Dimension(350, 220)); + + builder.addComponentRow(listScrollPane); + + JDialog dlg = builder.build(); + dlg.setVisible(true); + } +} diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/impl/Plc4xPlantModel.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/impl/Plc4xPlantModel.java new file mode 100644 index 000000000..a6024d883 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/impl/Plc4xPlantModel.java @@ -0,0 +1,170 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.malbec.s88.plant.impl; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import javax.swing.*; +import javax.swing.event.ChangeListener; + +import org.apache.plc4x.malbec.s88.api.*; +import org.apache.plc4x.malbec.s88.plant.services.S88ProjectServices; +import org.netbeans.api.project.Project; +import org.openide.filesystems.FileChangeAdapter; +import org.openide.filesystems.FileEvent; +import org.openide.filesystems.FileObject; +import org.openide.util.ChangeSupport; + +/** + * NetBeans-aware wrapper for S88PlantModel. Manages the plant.xml manifest + * lifecycle and synchronization. + */ +public class Plc4xPlantModel implements S88ChangeListener { + + private final Project project; + private final ChangeSupport cs = new ChangeSupport(this); + private S88PlantModel model; + private FileObject plantXml; + private final FileChangeAdapter fileListener; + + public Plc4xPlantModel(Project project) { + this.project = project; + this.fileListener = new FileChangeAdapter() { + @Override + public void fileChanged(FileEvent fe) { + reload(); + } + + @Override + public void fileDeleted(FileEvent fe) { + model = null; + cs.fireChange(); + } + }; + reload(); + } + + public final void reload() { + plantXml = project.getProjectDirectory().getFileObject("plant.xml"); + if (plantXml != null) { + plantXml.removeFileChangeListener(fileListener); + plantXml.addFileChangeListener(fileListener); + if (model != null) { + model.removeChangeListener(this); + } + S88Repository repo = S88ProjectServices.createRepository("xml", new FileObjectStorage(plantXml)); + model = repo.loadPlant(); + if (model != null) { + model.addChangeListener(this); + } + cs.fireChange(); + } else { + model = null; + cs.fireChange(); + } + } + + public S88PlantModel getModel() { + return model; + } + + public S88Element getElementByID(String id) { + return model != null ? model.findById(id).orElse(null) : null; + } + + public void addChangeListener(ChangeListener cl) { + cs.addChangeListener(cl); + } + + public void removeChangeListener(ChangeListener cl) { + cs.removeChangeListener(cl); + } + + @Override + public void onS88Change(S88ChangeEvent event) { + cs.fireChange(); + } + + public void save() throws IOException { + if (model == null) { + return; + } + plantXml = project.getProjectDirectory().getFileObject("plant.xml"); + if (plantXml == null) { + plantXml = project.getProjectDirectory().createData("plant.xml"); + } + S88Repository repo = S88ProjectServices.createRepository("xml", new FileObjectStorage(plantXml)); + plantXml.removeFileChangeListener(fileListener); + try{ + repo.savePlant(model); + } finally { + plantXml.addFileChangeListener(fileListener); + } + } + + public S88Element createRoot(String id) { + model = new S88PlantModel(new S88Element()); + model.getRoot().setId(id).setLevel(S88Level.AREA); + model.addChangeListener(this); + cs.fireChange(); + return model.getRoot(); + } + + public Project getProject() { + return project; + } + + public void export(String outputPath) { + try { + File file = new File(outputPath); + S88Storage storage = new S88Storage() { + @Override + public InputStream openInput() throws IOException { + return new FileInputStream(file); + } + + @Override + public OutputStream openOutput() throws IOException { + return new FileOutputStream(file); + } + }; + S88Repository repo = S88ProjectServices.createRepository("axml", storage); + repo.savePlant(model); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private record FileObjectStorage(FileObject fo) implements S88Storage { + + @Override + public InputStream openInput() throws IOException { + return fo.getInputStream(); + } + + @Override + public OutputStream openOutput() throws IOException { + return fo.getOutputStream(); + } + } +} diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/impl/Plc4xPlantSubProjectFactoryImpl.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/impl/Plc4xPlantSubProjectFactoryImpl.java new file mode 100644 index 000000000..5ec5c2fe9 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/impl/Plc4xPlantSubProjectFactoryImpl.java @@ -0,0 +1,50 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.malbec.s88.plant.impl; + +import java.io.IOException; +import org.netbeans.api.project.Project; +import org.netbeans.spi.project.ProjectFactory; +import org.netbeans.spi.project.ProjectState; +import org.openide.filesystems.FileObject; +import org.openide.util.lookup.ServiceProvider; + +/** + * Factory to recognize the 'plant.xml' file as a subproject. + */ +@ServiceProvider(service=ProjectFactory.class) +public class Plc4xPlantSubProjectFactoryImpl implements ProjectFactory { + + public static final String PLANT_PROJECT_FILE = "plant.xml"; + + @Override + public boolean isProject(FileObject fo) { + return fo.isFolder() && fo.getFileObject(PLANT_PROJECT_FILE) != null; + } + + @Override + public Project loadProject(FileObject fo, ProjectState ps) throws IOException { + return isProject(fo) ? new Plc4xPlantSubProjectImpl(fo, ps) : null; + } + + @Override + public void saveProject(Project prjct) throws IOException, ClassCastException { + // No special saving needed for now + } +} diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/impl/Plc4xPlantSubProjectImpl.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/impl/Plc4xPlantSubProjectImpl.java new file mode 100644 index 000000000..0df9b70f9 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/impl/Plc4xPlantSubProjectImpl.java @@ -0,0 +1,102 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.malbec.s88.plant.impl; + +import java.awt.Image; +import java.beans.PropertyChangeListener; +import javax.swing.Icon; +import javax.swing.ImageIcon; +import org.netbeans.api.annotations.common.StaticResource; +import org.netbeans.api.project.Project; +import org.netbeans.api.project.ProjectInformation; +import org.netbeans.spi.project.ProjectState; +import org.openide.filesystems.FileObject; +import org.openide.util.ImageUtilities; +import org.openide.util.Lookup; +import org.openide.util.lookup.Lookups; + +/** + * Implementation of the Plant subproject. + */ +public class Plc4xPlantSubProjectImpl implements Project { + + private final FileObject projectDir; + private final ProjectState state; + private Lookup lkp; + + public Plc4xPlantSubProjectImpl(FileObject projectDir, ProjectState state) { + this.projectDir = projectDir; + this.state = state; + } + + @Override + public FileObject getProjectDirectory() { + return projectDir; + } + + @Override + public Lookup getLookup() { + if (lkp == null) { + lkp = Lookups.fixed(new Object[]{ + new Plc4xPlantSubProjectInformation(), + new Plc4xPlantSubProjectLogicalViewProviderImpl(this), + new Plc4xPlantModel(this) + }); + } + return lkp; + } + + private class Plc4xPlantSubProjectInformation implements ProjectInformation { + + @StaticResource() + public static final String PLANT_SUBPROJECT_ICON = "org/apache/plc4x/malbec/s88/plant/nodes/PlantNode.png"; + + @Override + public String getName() { + return getProjectDirectory().getName(); + } + + @Override + public String getDisplayName() { + return getName(); + } + + @Override + public Icon getIcon() { + Image img = ImageUtilities.loadImage(PLANT_SUBPROJECT_ICON); + if (img == null) { + return null; + } + return new ImageIcon(img); + } + + @Override + public Project getProject() { + return Plc4xPlantSubProjectImpl.this; + } + + @Override + public void addPropertyChangeListener(PropertyChangeListener pl) { + } + + @Override + public void removePropertyChangeListener(PropertyChangeListener pl) { + } + } +} diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/impl/Plc4xPlantSubProjectLogicalViewProviderImpl.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/impl/Plc4xPlantSubProjectLogicalViewProviderImpl.java new file mode 100644 index 000000000..2941b23b3 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/impl/Plc4xPlantSubProjectLogicalViewProviderImpl.java @@ -0,0 +1,129 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.malbec.s88.plant.impl; + +import java.awt.Image; +import java.util.ArrayList; +import java.util.List; +import javax.swing.Action; +import org.apache.plc4x.malbec.s88.plant.actions.CreatePlantElementAction; +import org.netbeans.api.annotations.common.StaticResource; +import org.netbeans.api.project.Project; +import org.netbeans.spi.project.ui.LogicalViewProvider; +import org.netbeans.spi.project.ui.support.CommonProjectActions; +import org.netbeans.spi.project.ui.support.NodeFactorySupport; +import org.openide.filesystems.FileObject; +import org.openide.loaders.DataFolder; +import org.openide.loaders.DataObjectNotFoundException; +import org.openide.nodes.AbstractNode; +import org.openide.nodes.Children; +import org.openide.nodes.FilterNode; +import org.openide.nodes.Node; +import org.openide.util.Exceptions; +import org.openide.util.ImageUtilities; +import org.openide.util.Utilities; +import org.openide.util.lookup.Lookups; +import org.openide.util.lookup.ProxyLookup; + +/** + * LogicalViewProvider for the Plant subproject. + */ +public class Plc4xPlantSubProjectLogicalViewProviderImpl implements LogicalViewProvider { + + @StaticResource() + public static final String PLANT_SUBPROJECT_ICON = "org/apache/plc4x/malbec/s88/plant/nodes/PlantNode.png"; + + private final Project project; + + public Plc4xPlantSubProjectLogicalViewProviderImpl(Project project) { + this.project = project; + } + + @Override + public Node createLogicalView() { + try { + FileObject projectDirectory = project.getProjectDirectory(); + DataFolder projectFolder = DataFolder.findFolder(projectDirectory); + Node nodeOfProjectFolder = projectFolder.getNodeDelegate(); + return new ProjectNode(nodeOfProjectFolder, project); + } catch (DataObjectNotFoundException donfe) { + Exceptions.printStackTrace(donfe); + return new AbstractNode(Children.LEAF); + } + } + + @Override + public Node findPath(Node node, Object o) { + return null; + } + + private static final class ProjectNode extends FilterNode { + + final Project project; + + public ProjectNode(Node node, Project project) + throws DataObjectNotFoundException { + super(node, + NodeFactorySupport.createCompositeChildren(project, "Projects/org-plc4x-plant-project/Nodes"), + new ProxyLookup( + Lookups.singleton(project), + node.getLookup())); + this.project = project; + } + + @Override + public Action[] getActions(boolean arg0) { + List actions = new ArrayList<>(); + actions.add(new CreatePlantElementAction().createContextAwareInstance(getLookup())); + actions.addAll(Utilities.actionsForPath("Projects/org-plc4x-plant-project/Actions")); + actions.add(null); + actions.add(CommonProjectActions.copyProjectAction()); + actions.add(CommonProjectActions.deleteProjectAction()); + actions.add(CommonProjectActions.closeProjectAction()); + actions.add(null); + actions.add(CommonProjectActions.customizeProjectAction()); + return actions.toArray(new Action[0]); + } + + @Override + public Image getIcon(int type) { + return ImageUtilities.loadImage(PLANT_SUBPROJECT_ICON); + } + + @Override + public Image getOpenedIcon(int type) { + return getIcon(type); + } + + @Override + public String getDisplayName() { + return project.getProjectDirectory().getName(); + } + + @Override + public boolean canDestroy(){ + return true; + } + + @Override + public void destroy(){ + + } + } +} diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/impl/Plc4xPlantSubProjectProviderImpl.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/impl/Plc4xPlantSubProjectProviderImpl.java new file mode 100644 index 000000000..0c6d4d70e --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/impl/Plc4xPlantSubProjectProviderImpl.java @@ -0,0 +1,108 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.malbec.s88.plant.impl; + +import java.awt.*; +import java.io.IOException; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import javax.swing.event.ChangeListener; +import org.netbeans.api.project.Project; +import org.netbeans.api.project.ProjectManager; +import org.netbeans.spi.project.SubprojectProvider; +import org.openide.filesystems.FileChangeAdapter; +import org.openide.filesystems.FileEvent; +import org.openide.filesystems.FileObject; +import org.openide.util.ChangeSupport; + +/** + * Provider to discover the 'plant' subproject. + */ +public class Plc4xPlantSubProjectProviderImpl implements SubprojectProvider { + + public static final String PLANT_SUBPROJECT_FILE = "plant.xml"; + + private final Project project; + private final ChangeSupport cs = new ChangeSupport(this); + private final FileChangeAdapter fileListener; + + public Plc4xPlantSubProjectProviderImpl(Project project) { + this.project = project; + this.fileListener = new FileChangeAdapter() { + @Override + public void fileDeleted(FileEvent fe) { + cs.fireChange(); + } + + @Override + public void fileDataCreated(FileEvent fe) { + if (fe.getFile().getNameExt().equals(PLANT_SUBPROJECT_FILE)) { + EventQueue.invokeLater(cs::fireChange); + } + } + + }; + // recursive listening + java.io.File projectDirFile = org.openide.filesystems.FileUtil.toFile(project.getProjectDirectory()); + if (projectDirFile != null) { + org.openide.filesystems.FileUtil.addFileChangeListener(fileListener, projectDirFile); + } + } + + @Override + public Set getSubprojects() { + return loadProjects(project.getProjectDirectory()); + } + + @Override + public void addChangeListener(ChangeListener cl) { + cs.addChangeListener(cl); + } + + @Override + public void removeChangeListener(ChangeListener cl) { + cs.removeChangeListener(cl); + } + + public void fireChange() { + cs.fireChange(); + } + + public Set loadProjects(FileObject dir) { + + dir.refresh(); + Set newProjects = new HashSet<>(); + for (FileObject sub : dir.getChildren()) { + if (sub.isFolder()) { + try { + + sub.refresh(); + Project subp = ProjectManager.getDefault().findProject(sub); + if (subp instanceof Plc4xPlantSubProjectImpl) { + newProjects.add(subp); + } + } catch (IOException | IllegalArgumentException ex) { + // Ignore non-projects + } + } + } + return Collections.unmodifiableSet(newProjects); + } +} diff --git a/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/event/DeviceEvent.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/nodes/AreaNode.java similarity index 71% rename from plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/event/DeviceEvent.java rename to plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/nodes/AreaNode.java index 7f659b5cf..7da791616 100644 --- a/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/event/DeviceEvent.java +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/nodes/AreaNode.java @@ -16,15 +16,18 @@ * specific language governing permissions and limitations * under the License. */ +package org.apache.plc4x.malbec.s88.plant.nodes; -package org.apache.plc4x.java.tools.ui.event; +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.netbeans.api.project.Project; -import org.apache.plc4x.java.tools.ui.model.Device; - -public class DeviceEvent extends UiApplicationEvent { +/** + * Specialized node for ISA-88 Area. + */ +public class AreaNode extends PlantElementNode { - public DeviceEvent(Device source, EventType eventType) { - super(source, eventType); + public AreaNode(Project project, S88Element element) { + super(project, element); } } diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/nodes/EquipmentModuleNode.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/nodes/EquipmentModuleNode.java new file mode 100644 index 000000000..40b6defdc --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/nodes/EquipmentModuleNode.java @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.malbec.s88.plant.nodes; + +import javax.swing.Action; +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.plant.actions.CreatePlantElementAction; +import org.apache.plc4x.malbec.s88.plant.actions.CreateTemplateAction; +import org.apache.plc4x.malbec.s88.plant.actions.PropertiesAction; +import org.apache.plc4x.malbec.s88.plant.actions.ViewTemplatesAction; +import org.netbeans.api.project.Project; +import org.openide.nodes.Children; +import org.openide.util.Utilities; + +import java.util.ArrayList; +import java.util.List; + +/** + * Specialized node for ISA-88 Equipment Module. + */ +public class EquipmentModuleNode extends PlantElementNode { + + public EquipmentModuleNode(Project project, S88Element element) { + super(project, element); + this.setChildren(Children.LEAF); + } + + @Override + protected String getDefaultIconResource() { + return "org/apache/plc4x/malbec/s88/plant/nodes/EquipmentModule.png"; + } + + @Override + public Action[] getActions(boolean context) { + List actions = new ArrayList<>(); + actions.add(org.openide.util.actions.SystemAction.get(org.openide.actions.OpenAction.class)); + actions.add(null); + actions.addAll(Utilities.actionsForPath("Projects/org-plc4x-plant-element/Actions")); + actions.add(new PropertiesAction().createContextAwareInstance(getLookup())); + return actions.toArray(Action[]::new); + } +} diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/nodes/PlantElementNode.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/nodes/PlantElementNode.java new file mode 100644 index 000000000..0b9e2d56c --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/nodes/PlantElementNode.java @@ -0,0 +1,245 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.malbec.s88.plant.nodes; + +import java.awt.Image; +import java.util.ArrayList; +import java.util.List; +import javax.swing.Action; +import javax.swing.event.ChangeEvent; +import javax.swing.event.ChangeListener; +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.api.S88Level; +import org.apache.plc4x.malbec.s88.core.RenameElementUseCase; +import org.apache.plc4x.malbec.s88.core.UpdatePropertyUseCase; +import org.apache.plc4x.malbec.s88.plant.actions.CreatePlantElementAction; +import org.apache.plc4x.malbec.s88.plant.actions.CreateTemplateAction; +import org.apache.plc4x.malbec.s88.plant.actions.PropertiesAction; +import org.apache.plc4x.malbec.s88.plant.actions.ViewTemplatesAction; +import org.apache.plc4x.malbec.s88.plant.impl.Plc4xPlantModel; +import org.netbeans.api.project.Project; +import org.openide.DialogDisplayer; +import org.openide.NotifyDescriptor; +import org.openide.nodes.AbstractNode; +import org.openide.nodes.ChildFactory; +import org.openide.nodes.Children; +import org.openide.nodes.Node; +import org.openide.util.Exceptions; +import org.openide.util.NbBundle.Messages; +import org.openide.util.Utilities; +import org.openide.util.lookup.AbstractLookup; +import org.openide.util.lookup.InstanceContent; +/** + * Node representing an ISA-88 Plant Element. + */ +@Messages({ + "# {0} - element id", + "ERR_DuplicateID=Element ID ''{0}'' already exists.", + "ERR_EmptyID=Element ID cannot be empty." +}) +public class PlantElementNode extends AbstractNode implements ChangeListener { + + protected final Project project; + protected final Plc4xPlantModel model; + private String equipmentID; + private final S88Level equipmentLevel; + private final PlantElementChildrenFactory factory; + private final InstanceContent content; + protected S88Element currentElement; + + public PlantElementNode(Project project, S88Element element) { + this(project, element, new InstanceContent()); + } + + protected PlantElementNode(Project project, S88Element element, InstanceContent content) { + this(project, element, new PlantElementChildrenFactory(project, element), content); + } + + private PlantElementNode(Project project, S88Element element, PlantElementChildrenFactory factory, InstanceContent content) { + super(Children.create(factory, true), new AbstractLookup(content)); + this.project = project; + this.model = project.getLookup().lookup(Plc4xPlantModel.class); + this.factory = factory; + this.content = content; + this.currentElement = element; + this.content.add(project); + this.content.add(currentElement); + this.content.add(this); + + this.equipmentID = element.getId(); + this.equipmentLevel = element.getLevel(); + + if (equipmentLevel != S88Level.NULL) { + this.content.add(new PlantElementOpenCookie(this.model, this.currentElement)); + } + + if (model != null) { + model.addChangeListener(this); + } + } + + @Override + public void stateChanged(ChangeEvent e) { + refreshNode(); + } + + private void refreshNode() { + if (model != null) { + S88Element freshEq = model.getElementByID(equipmentID); + if (freshEq != null) { + updateElement(freshEq); + } + } + } + + private void updateElement(S88Element freshEq) { + content.remove(currentElement); + currentElement = freshEq; + content.add(currentElement); + factory.updateParent(currentElement); + this.equipmentID = freshEq.getId(); + fireDisplayNameChange(null, getDisplayName()); + fireIconChange(); + fireOpenedIconChange(); + } + + @Override + public String getDisplayName() { + return equipmentID; + } + + @Override + public Image getIcon(int type) { + return S88NodeIconUtil.resolveIcon(String.valueOf(currentElement.getProperty("icon")), getDefaultIconResource()); + } + + protected String getDefaultIconResource() { + return "org/apache/plc4x/malbec/s88/plant/nodes/PlantNode.png"; + } + + @Override + public Image getOpenedIcon(int type) { + return getIcon(type); + } + + @Override + public Action[] getActions(boolean context) { + List actions = new ArrayList<>(); + actions.add(org.openide.util.actions.SystemAction.get(org.openide.actions.OpenAction.class)); + actions.add(new ViewTemplatesAction().createContextAwareInstance(getLookup())); + actions.add(null); + actions.add(new CreatePlantElementAction().createContextAwareInstance(getLookup())); + actions.add(new CreateTemplateAction().createContextAwareInstance(getLookup())); + actions.add(null); + actions.addAll(Utilities.actionsForPath("Projects/org-plc4x-plant-element/Actions")); + actions.add(new PropertiesAction().createContextAwareInstance(getLookup())); + return actions.toArray(new Action[0]); + } + + @Override + public Action getPreferredAction(){ + return org.openide.util.actions.SystemAction.get(org.openide.actions.OpenAction.class); + } + + protected void updateProperty(String key, String value) { + if (model != null) { + try { + UpdatePropertyUseCase.execute(model.getModel(), currentElement, key, value); + model.save(); + } catch (Exception ex) { + Exceptions.printStackTrace(ex); + } + } + } + + private void updateEquipmentID(String newID) { + if (model != null) { + try { + RenameElementUseCase.execute(model.getModel(), currentElement, newID); + model.save(); + this.equipmentID = currentElement.getId(); + updateElement(currentElement); + } catch (IllegalArgumentException | IllegalStateException ex) { + DialogDisplayer.getDefault().notify(new NotifyDescriptor.Message(ex.getMessage(), NotifyDescriptor.ERROR_MESSAGE)); + } catch (Exception ex) { + Exceptions.printStackTrace(ex); + } + } + } + + private static class PlantElementChildrenFactory extends ChildFactory implements ChangeListener { + + private final Project project; + private final Plc4xPlantModel model; + private S88Element parent; + + public PlantElementChildrenFactory(Project project, S88Element parent) { + this.project = project; + this.model = project.getLookup().lookup(Plc4xPlantModel.class); + this.parent = parent; + if (model != null) { + model.addChangeListener(this); + } + } + + public void updateParent(S88Element newParent) { + this.parent = newParent; + refresh(true); + } + + @Override + protected boolean createKeys(List list) { + for (S88Element child : parent.getChildren()) { + list.add(child.getId()); + } + return true; + } + + @Override + protected Node createNodeForKey(String key) { + if (model != null) { + S88Element et = model.getElementByID(key); + if (et != null) { + try { + return PlantNodeFactoryUtil.createNode(project, et); + } catch (Exception ex) { + Exceptions.printStackTrace(ex); + } + } + } + return null; + } + + @Override + public void stateChanged(ChangeEvent e) { + refresh(true); + } + } + + @Override + public boolean canRename() { + return true; + } + + @Override + public void setName(String newName) { + updateEquipmentID(newName); + super.setName(newName); + } +} diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/nodes/PlantElementOpenCookie.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/nodes/PlantElementOpenCookie.java new file mode 100644 index 000000000..c036cf9e0 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/nodes/PlantElementOpenCookie.java @@ -0,0 +1,65 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.malbec.s88.plant.nodes; + +import java.util.Set; +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.plant.impl.Plc4xPlantModel; +import org.apache.plc4x.malbec.s88.plant.panels.EditorTopComponent; +import org.openide.cookies.OpenCookie; +import org.openide.windows.TopComponent; +import org.openide.windows.WindowManager; + +/** + * OpenCookie that opens a specialized editor for a plant element. + */ +public class PlantElementOpenCookie implements OpenCookie { + + private final Plc4xPlantModel model; + private final S88Element element; + + public PlantElementOpenCookie(Plc4xPlantModel model, S88Element element) { + this.model = model; + this.element = element; + } + + @Override + public void open() { + EditorTopComponent editor = findExistingEditor(); + + if (editor == null) { + editor = new EditorTopComponent(model, element); + editor.open(); + } + + editor.requestActive(); + } + + private EditorTopComponent findExistingEditor() { + Set opened = WindowManager.getDefault().getRegistry().getOpened(); + for (TopComponent tc : opened) { + if (tc instanceof EditorTopComponent etc) { + if (etc.getElement() != null && etc.getElement().getId().equals(element.getId())) { + return etc; + } + } + } + return null; + } +} diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/nodes/PlantHierarchyNodeFactory.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/nodes/PlantHierarchyNodeFactory.java new file mode 100644 index 000000000..562a03ed8 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/nodes/PlantHierarchyNodeFactory.java @@ -0,0 +1,110 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.malbec.s88.plant.nodes; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import javax.swing.event.ChangeEvent; +import javax.swing.event.ChangeListener; +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.api.S88PlantModel; +import org.apache.plc4x.malbec.s88.plant.impl.Plc4xPlantModel; +import org.netbeans.api.project.Project; +import org.netbeans.spi.project.ui.support.NodeFactory; +import org.netbeans.spi.project.ui.support.NodeFactorySupport; +import org.netbeans.spi.project.ui.support.NodeList; +import org.openide.nodes.Node; +import org.openide.util.ChangeSupport; +import org.openide.util.Exceptions; + +/** + * Node factory to populate the Plant project from the S88 model. + */ +@NodeFactory.Registration(projectType = "org-plc4x-plant-project", position = 10) +public class PlantHierarchyNodeFactory implements NodeFactory { + + @Override + public NodeList createNodes(Project p) { + Plc4xPlantModel model = p.getLookup().lookup(Plc4xPlantModel.class); + if (model == null) { + return NodeFactorySupport.fixedNodeList(); + } + return new PlantHierarchyNodeList(model); + } + + private static class PlantHierarchyNodeList implements NodeList, ChangeListener { + + private final Plc4xPlantModel model; + private final ChangeSupport cs = new ChangeSupport(this); + + public PlantHierarchyNodeList(Plc4xPlantModel model) { + this.model = model; + } + + @Override + public List keys() { + S88PlantModel s88Model = model.getModel(); + if (s88Model != null && s88Model.getRoot() != null) { + return s88Model.getRoot().getChildren().stream() + .map(S88Element::getId) + .collect(Collectors.toList()); + } + return Collections.emptyList(); + } + + @Override + public Node node(String key) { + S88Element et = model.getElementByID(key); + if (et != null) { + try { + return PlantNodeFactoryUtil.createNode(model.getProject(), et); + } catch (Exception ex) { + Exceptions.printStackTrace(ex); + } + } + return null; + } + + @Override + public void addChangeListener(ChangeListener cl) { + cs.addChangeListener(cl); + } + + @Override + public void removeChangeListener(ChangeListener cl) { + cs.removeChangeListener(cl); + } + + @Override + public void addNotify() { + model.addChangeListener(this); + } + + @Override + public void removeNotify() { + model.removeChangeListener(this); + } + + @Override + public void stateChanged(ChangeEvent e) { + cs.fireChange(); + } + } +} diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/nodes/PlantNodeFactoryUtil.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/nodes/PlantNodeFactoryUtil.java new file mode 100644 index 000000000..3f1bc8dc5 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/nodes/PlantNodeFactoryUtil.java @@ -0,0 +1,42 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.malbec.s88.plant.nodes; + +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.api.S88Level; +import org.netbeans.api.project.Project; +import org.openide.nodes.Node; + +/** + * Utility to instantiate the correct specialized node based on ISA-88 Level. + */ +public class PlantNodeFactoryUtil { + + public static Node createNode(Project project, S88Element element) { + S88Level level = element.getLevel(); + + return switch (level) { + case AREA -> new AreaNode(project, element); + case PROCESSCELL -> new ProcessCellNode(project, element); + case UNIT -> new UnitNode(project, element); + case EQUIPMENTMODULE -> new EquipmentModuleNode(project, element); + default -> new PlantElementNode(project, element); + }; + } +} diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/nodes/Plc4xPlantSubProjectNodeFactoryImpl.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/nodes/Plc4xPlantSubProjectNodeFactoryImpl.java new file mode 100644 index 000000000..33f1765cf --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/nodes/Plc4xPlantSubProjectNodeFactoryImpl.java @@ -0,0 +1,102 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.malbec.s88.plant.nodes; + +import java.util.ArrayList; +import java.util.List; +import javax.swing.event.ChangeEvent; +import javax.swing.event.ChangeListener; +import org.apache.plc4x.malbec.s88.plant.impl.Plc4xPlantSubProjectProviderImpl; +import org.netbeans.api.project.Project; +import org.netbeans.spi.project.ui.LogicalViewProvider; +import org.netbeans.spi.project.ui.support.NodeFactory; +import org.netbeans.spi.project.ui.support.NodeList; +import org.netbeans.spi.project.ui.support.NodeFactorySupport; +import org.openide.nodes.FilterNode; +import org.openide.nodes.Node; +import org.openide.util.ChangeSupport; +import org.openide.util.Exceptions; + +/** + * Node factory to include the Plant subproject in the main S88 project tree. + */ +@NodeFactory.Registration(projectType = "org-plc4x-s88-project", position = 15) +public class Plc4xPlantSubProjectNodeFactoryImpl implements NodeFactory { + + @Override + public NodeList createNodes(Project project) { + Plc4xPlantSubProjectProviderImpl provider = project.getLookup().lookup(Plc4xPlantSubProjectProviderImpl.class); + if (provider == null) { + return NodeFactorySupport.fixedNodeList(); + } + return new PlantProjectsNodeList(provider); + } + + private static class PlantProjectsNodeList implements NodeList, ChangeListener { + private final Plc4xPlantSubProjectProviderImpl provider; + private final ChangeSupport cs = new ChangeSupport(this); + + public PlantProjectsNodeList(Plc4xPlantSubProjectProviderImpl provider) { + this.provider = provider; + } + + @Override + public List keys() { + return new ArrayList<>(provider.getSubprojects()); + } + + @Override + public void addChangeListener(ChangeListener cl) { + cs.addChangeListener(cl); + } + + @Override + public void removeChangeListener(ChangeListener cl) { + cs.removeChangeListener(cl); + } + + @Override + public Node node(Project k) { + try { + LogicalViewProvider lvp = k.getLookup().lookup(LogicalViewProvider.class); + if (lvp != null) { + return new FilterNode(lvp.createLogicalView()); + } + } catch (Exception ex) { + Exceptions.printStackTrace(ex); + } + return null; + } + + @Override + public void addNotify() { + provider.addChangeListener(this); + } + + @Override + public void removeNotify() { + provider.removeChangeListener(this); + } + + @Override + public void stateChanged(ChangeEvent e) { + cs.fireChange(); + } + } +} diff --git a/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/model/Driver.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/nodes/ProcessCellNode.java similarity index 57% rename from plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/model/Driver.java rename to plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/nodes/ProcessCellNode.java index bb5a992cc..c44ae71e5 100644 --- a/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/model/Driver.java +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/nodes/ProcessCellNode.java @@ -16,34 +16,28 @@ * specific language governing permissions and limitations * under the License. */ +package org.apache.plc4x.malbec.s88.plant.nodes; -package org.apache.plc4x.java.tools.ui.model; +import javax.swing.Action; +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.netbeans.api.project.Project; -import org.apache.plc4x.java.api.metadata.PlcDriverMetadata; - -public class Driver { - - private final String code; - private final String name; - private final PlcDriverMetadata metadata; - - - public Driver(String code, String name, PlcDriverMetadata metadata) { - this.code = code; - this.name = name; - this.metadata = metadata; - } +/** + * Specialized node for ISA-88 Process Cell. + */ +public class ProcessCellNode extends PlantElementNode { - public String getCode() { - return code; + public ProcessCellNode(Project project, S88Element element) { + super(project, element); } - public String getName() { - return name; + @Override + protected String getDefaultIconResource() { + return "org/apache/plc4x/malbec/s88/plant/nodes/ProcessCell.png"; } - public PlcDriverMetadata getMetadata() { - return metadata; + @Override + public Action[] getActions(boolean context) { + return super.getActions(context); } - } diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/nodes/S88NodeIconUtil.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/nodes/S88NodeIconUtil.java new file mode 100644 index 000000000..27956f832 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/nodes/S88NodeIconUtil.java @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.plc4x.malbec.s88.plant.nodes; + +import java.awt.Image; +import java.io.File; +import javax.imageio.ImageIO; +import org.openide.util.ImageUtilities; + +/** + * Utility to resolve icons for S88 nodes. + */ +public class S88NodeIconUtil { + private S88NodeIconUtil() { + /* This utility class should not be instantiated */ + } + + + public static Image resolveIcon(String iconPath, String defaultResource) { + if (iconPath != null && !iconPath.isEmpty()) { + Image img = null; + try { + File f = new File(iconPath); + if (f.isAbsolute() && f.exists()) { + img = ImageIO.read(f); + } + } catch (Exception e) { + // Ignore + } + + if (img == null) { + String resourcePath = iconPath; + if (resourcePath.startsWith("/")) { + resourcePath = resourcePath.substring(1); + } + img = ImageUtilities.loadImage(resourcePath, true); + } + + if (img != null) { + return img; + } + } + return ImageUtilities.loadImage(defaultResource); + } +} diff --git a/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/event/UiApplicationEvent.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/nodes/UnitNode.java similarity index 59% rename from plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/event/UiApplicationEvent.java rename to plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/nodes/UnitNode.java index 45b585629..212e6c04c 100644 --- a/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/event/UiApplicationEvent.java +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/nodes/UnitNode.java @@ -16,27 +16,28 @@ * specific language governing permissions and limitations * under the License. */ +package org.apache.plc4x.malbec.s88.plant.nodes; -package org.apache.plc4x.java.tools.ui.event; +import javax.swing.Action; +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.netbeans.api.project.Project; -public abstract class UiApplicationEvent extends org.springframework.context.ApplicationEvent { - - private final EventType eventType; +/** + * Specialized node for ISA-88 Unit. + */ +public class UnitNode extends PlantElementNode { - public UiApplicationEvent(T source, EventType eventType) { - super(source); - this.eventType = eventType; + public UnitNode(Project project, S88Element element) { + super(project, element); } - public EventType getEventType() { - return eventType; + @Override + protected String getDefaultIconResource() { + return "org/apache/plc4x/malbec/s88/plant/nodes/Unit.png"; } @Override - public T getSource() { - // We're only accepting objects of type T, so in this case it's safe to cast. - //noinspection unchecked - return (T) super.getSource(); + public Action[] getActions(boolean context) { + return super.getActions(context); } - } diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/AttributeDialogBuilder.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/AttributeDialogBuilder.java new file mode 100644 index 000000000..bc579ed78 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/AttributeDialogBuilder.java @@ -0,0 +1,501 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.plc4x.malbec.s88.plant.panels; + +import org.apache.plc4x.malbec.s88.api.DataType; +import org.apache.plc4x.malbec.s88.api.EngineeringUnits; +import org.apache.plc4x.malbec.s88.api.S88Enumeration; + +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import javax.swing.border.TitledBorder; +import javax.swing.text.AttributeSet; +import javax.swing.text.BadLocationException; +import javax.swing.text.DocumentFilter; +import javax.swing.text.PlainDocument; +import java.awt.*; +import java.awt.event.ItemEvent; +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.BiConsumer; + +public class AttributeDialogBuilder { + + private final String title; + private boolean isEditMode = false; + private String initialName = ""; + private Map initialProps = null; + private List enumerations = new ArrayList<>(); + private Window owner; + private boolean editable = false; + + private BiConsumer> onSaveAction; + private Runnable onUpdateCallback; + + protected JTextField txtName; + protected JComboBox comboType; + protected JComboBox comboEnumeration; + protected JComboBox comboEngineeringUnit; + protected JRadioButton radStatic; + protected JRadioButton radDynamic; + protected JPanel staticField; + protected JTextField txtStaticValue; + protected JComboBox comboStaticValue; + protected JTextField txtReference; + protected boolean hasReference = true; + protected JButton btnOk; + protected JButton btnCancel; + + private final ValueDocumentFilter valueFilter = new ValueDocumentFilter(); + + public AttributeDialogBuilder(String title) { + this.title = title; + } + + public AttributeDialogBuilder withEnumerations(List enumerations) { + this.enumerations = enumerations != null ? new ArrayList<>(enumerations) : new ArrayList<>(); + return this; + } + + public AttributeDialogBuilder withInitialData(String name, Map props) { + this.isEditMode = true; + this.initialName = name; + this.initialProps = props; + return this; + } + + public AttributeDialogBuilder onSave(BiConsumer> action) { + this.onSaveAction = action; + return this; + } + + public AttributeDialogBuilder onUpdate(Runnable callback) { + this.onUpdateCallback = callback; + return this; + } + + public AttributeDialogBuilder withOwner(Window owner) { + this.owner = owner; + return this; + } + + public AttributeDialogBuilder withEditable(boolean editable) { + this.editable = editable; + return this; + } + + public void show() { + JDialog dialog = new JDialog(owner, title, Dialog.ModalityType.APPLICATION_MODAL); + dialog.setResizable(false); + dialog.setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE); + + JPanel contentPane = new JPanel(new BorderLayout(10, 10)); + contentPane.setBorder(new EmptyBorder(10, 10, 10, 10)); + + createWidgets(); + populateLists(); + + JPanel topSection = new JPanel(new BorderLayout(15, 0)); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.insets = new Insets(3, 5, 3, 5); + gbc.weightx = 1.0; + topSection.add(buildFormPanel(gbc), BorderLayout.CENTER); + topSection.add(buildButtonPanel(), BorderLayout.EAST); + + JPanel dataSourceSection = buildDataSourceSection(); + + wireToggleListeners(); + applyInitialData(); + toggleDataSourceFields(); + toggleTypeFields(); + applyStoredStaticValue(); + + btnCancel.addActionListener(e -> dialog.dispose()); + btnOk.addActionListener(e -> { + if (onSaveAction != null) { + Map attributeBag = buildAttributeBag(); + onSaveAction.accept(txtName.getText(), attributeBag); + } + dialog.dispose(); + if (onUpdateCallback != null) { + onUpdateCallback.run(); + } + }); + + contentPane.add(topSection, BorderLayout.NORTH); + if (dataSourceSection != null) { + contentPane.add(dataSourceSection, BorderLayout.CENTER); + } + + dialog.setContentPane(contentPane); + dialog.pack(); + dialog.setLocationRelativeTo(null); + dialog.setVisible(true); + } + + protected void createWidgets() { + txtName = new JTextField(); + comboType = new JComboBox<>(DataType.displayNames()); + comboType.setEnabled(editable); + comboEnumeration = new JComboBox<>(); + comboEnumeration.setEnabled(editable); + comboEngineeringUnit = new JComboBox<>(); + comboEngineeringUnit.setEnabled(editable); + + radStatic = new JRadioButton("Static"); + radDynamic = new JRadioButton("Referenced", true); + staticField = new JPanel(new CardLayout()); + txtStaticValue = new JTextField(); + ((PlainDocument) txtStaticValue.getDocument()).setDocumentFilter(valueFilter); + comboStaticValue = new JComboBox<>(); + + staticField.add(txtStaticValue, "TEXT"); + staticField.add(comboStaticValue, "COMBO"); + + txtReference = new JTextField(); + txtReference.addKeyListener(new KeyAdapter() { + @Override + public void keyTyped(KeyEvent e){ + char c = e.getKeyChar(); + if (!Character.isDigit(c) && c != KeyEvent.VK_BACK_SPACE) { + e.consume(); + } + } + }); + } + + protected void populateLists() { + for (S88Enumeration enumeration : enumerations) { + if (enumeration.getName() != null) { + comboEnumeration.addItem(enumeration.getName()); + } + } + + for (EngineeringUnits engUnit : EngineeringUnits.values()) { + comboEngineeringUnit.addItem(engUnit); + } + + comboEngineeringUnit.setRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent(JList list, Object value, + int index, boolean isSelected, boolean cellHasFocus) { + super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + if (value instanceof EngineeringUnits) { + setText(((EngineeringUnits) value).name()); + } + return this; + } + }); + } + + protected boolean includeDataSourceSection() { + return true; + } + + protected JPanel buildFormPanel(GridBagConstraints gbc) { + JPanel formPanel = new JPanel(new GridBagLayout()); + int row = 0; + addFormField(formPanel, gbc, row++, "Name", txtName); + addFormField(formPanel, gbc, row++, "Type", comboType); + addFormField(formPanel, gbc, row++, "Enumeration", comboEnumeration); + addFormField(formPanel, gbc, row++, "Engineering Unit", comboEngineeringUnit); + return formPanel; + } + + protected JPanel buildButtonPanel() { + JPanel buttonPanel = new JPanel(); + buttonPanel.setLayout(new BoxLayout(buttonPanel, BoxLayout.Y_AXIS)); + btnOk = new JButton("OK"); + btnCancel = new JButton("Cancel"); + Dimension btnSize = new Dimension(80, 26); + btnOk.setMaximumSize(btnSize); + btnCancel.setMaximumSize(btnSize); + buttonPanel.add(btnOk); + buttonPanel.add(Box.createRigidArea(new Dimension(0, 10))); + buttonPanel.add(btnCancel); + return buttonPanel; + } + + protected JPanel buildDataSourceSection() { + if (!includeDataSourceSection()) { + return null; + } + + JPanel dataSourceSection = new JPanel(new GridBagLayout()); + dataSourceSection.setBorder(BorderFactory.createTitledBorder( + BorderFactory.createLineBorder(Color.LIGHT_GRAY), + "Data Source", TitledBorder.LEFT, TitledBorder.TOP)); + + GridBagConstraints gbcDs = new GridBagConstraints(); + gbcDs.fill = GridBagConstraints.HORIZONTAL; + gbcDs.insets = new Insets(3, 5, 3, 5); + int dsRow = 0; + + ButtonGroup bgSource = new ButtonGroup(); + bgSource.add(radStatic); + bgSource.add(radDynamic); + + gbcDs.gridx = 0; gbcDs.gridy = dsRow++; gbcDs.gridwidth = 2; gbcDs.weightx = 0.0; + dataSourceSection.add(radStatic, gbcDs); + gbcDs.gridwidth = 1; + addIndentedFormField(dataSourceSection, gbcDs, dsRow++, "Value", staticField); + + gbcDs.gridx = 0; gbcDs.gridy = dsRow++; gbcDs.gridwidth = 2; gbcDs.weightx = 0.0; + dataSourceSection.add(radDynamic, gbcDs); + gbcDs.gridwidth = 1; + if(hasReference) { + addIndentedFormField(dataSourceSection, gbcDs, dsRow++, "Reference", txtReference); + } + + gbcDs.gridx = 0; gbcDs.gridy = dsRow; gbcDs.gridwidth = 2; gbcDs.weighty = 1.0; + dataSourceSection.add(Box.createVerticalGlue(), gbcDs); + + return dataSourceSection; + } + + protected void refreshStaticValueCombo() { + String previous = Objects.toString(comboStaticValue.getSelectedItem(), ""); + comboStaticValue.removeAllItems(); + String enumName = Objects.toString(comboEnumeration.getSelectedItem(), ""); + for (S88Enumeration enumeration : enumerations) { + if (enumName.equals(enumeration.getName())) { + for (String label : enumeration.getValues().keySet()) { + comboStaticValue.addItem(label); + } + break; + } + } + if (!previous.isEmpty()) { + comboStaticValue.setSelectedItem(previous); + } + } + + protected void applyInitialData() { + if (isEditMode && initialProps != null) { + txtName.setText(initialName); + txtName.setEnabled(false); + if (initialProps.get("Type") != null) { + comboType.setSelectedItem(initialProps.get("Type").toString().trim().toUpperCase()); + } + + boolean isEnumeration = DataType.isEnumeration(Objects.toString(comboType.getSelectedItem(), "")); + if (isEnumeration) { + Object enumValue = initialProps.get("Enumeration"); + if (enumValue == null) { + enumValue = initialProps.get("Eng_Units/Enum"); + } + comboEnumeration.setSelectedItem(enumValue != null ? String.valueOf(enumValue) : null); + } else { + comboEngineeringUnit.setSelectedItem(EngineeringUnits.fromName(Objects.toString(initialProps.get("Eng_Units/Enum"), ""))); + txtStaticValue.setText(Objects.toString(initialProps.get("StaticValue"), "")); + } + + if(initialProps.get("Reference") != null) { + txtReference.setText(initialProps.get("Reference").toString()); + } + + if (initialProps.get("StaticValue") != null) { + radStatic.setSelected(true); + } else { + radDynamic.setSelected(true); + } + } else { + radDynamic.setSelected(true); + } + } + + protected void applyStoredStaticValue() { + if (isEditMode && initialProps != null + && DataType.isEnumeration(Objects.toString(comboType.getSelectedItem(), ""))) { + String stored = Objects.toString(initialProps.get("StaticValue"), ""); + if (!stored.isEmpty() && indexOfItem(comboStaticValue, stored) >= 0) { + comboStaticValue.setSelectedItem(stored); + } + } + } + + protected void toggleTypeFields() { + boolean isEnumeration = DataType.isEnumeration(Objects.toString(comboType.getSelectedItem(), "")); + if (editable) { + comboEnumeration.setEnabled(isEnumeration); + comboEngineeringUnit.setEnabled(!isEnumeration); + } + CardLayout cl = (CardLayout) staticField.getLayout(); + cl.show(staticField, isEnumeration ? "COMBO" : "TEXT"); + if (isEnumeration) { + refreshStaticValueCombo(); + } + applyValueInputFilter(); + } + + protected void toggleDataSourceFields() { + boolean isReferenced = radDynamic.isSelected(); + txtStaticValue.setEnabled(!isReferenced); + comboStaticValue.setEnabled(!isReferenced); + txtReference.setEnabled(isReferenced); + } + + protected void wireToggleListeners() { + comboType.addItemListener(e -> { + if (e.getStateChange() == ItemEvent.SELECTED) { + toggleTypeFields(); + } + }); + + comboEnumeration.addItemListener(e -> { + if (e.getStateChange() == ItemEvent.SELECTED) { + refreshStaticValueCombo(); + } + }); + + radStatic.addItemListener(e -> { + if (e.getStateChange() == ItemEvent.SELECTED) { + toggleDataSourceFields(); + } + }); + radDynamic.addItemListener(e -> { + if (e.getStateChange() == ItemEvent.SELECTED) { + toggleDataSourceFields(); + } + }); + + + } + + protected void applyValueInputFilter() { + valueFilter.setType(DataType.fromString(Objects.toString(comboType.getSelectedItem(), ""))); + } + + + + protected Map buildAttributeBag() { + Map attributeBag = isEditMode && initialProps != null + ? new LinkedHashMap<>(initialProps) : new LinkedHashMap<>(); + attributeBag.put("Type", Objects.toString(comboType.getSelectedItem(), "")); + attributeBag.remove("Enumeration"); + + boolean isEnumeration = DataType.isEnumeration(Objects.toString(comboType.getSelectedItem(), "")); + if (isEnumeration) { + attributeBag.put("Eng_Units/Enum", Objects.toString(comboEnumeration.getSelectedItem(), "")); + } else { + attributeBag.put("Eng_Units/Enum", comboEngineeringUnit.getSelectedItem() != null + ? ((EngineeringUnits) comboEngineeringUnit.getSelectedItem()).getName() + : ""); + } + + if (radStatic.isSelected()) { + attributeBag.put("StaticValue", isEnumeration + ? Objects.toString(comboStaticValue.getSelectedItem(), "") + : txtStaticValue.getText()); + attributeBag.remove("Reference"); + } else { + attributeBag.put("Reference", safeReference(txtReference.getText())); + attributeBag.remove("StaticValue"); + } + return attributeBag; + } + + private int safeReference(String text) { + if (text == null || text.trim().isEmpty()) { + return 0; + } + try { + return Integer.parseInt(text.trim()); + } catch (NumberFormatException e) { + return 0; + } + } + + protected void addFormField(JPanel parent, GridBagConstraints gbc, int row, String labelText, JComponent field) { + gbc.gridy = row; + gbc.gridx = 0; gbc.weightx = 0.0; gbc.anchor = GridBagConstraints.EAST; + parent.add(new JLabel(labelText), gbc); + gbc.gridx = 1; gbc.weightx = 1.0; gbc.anchor = GridBagConstraints.WEST; + parent.add(field, gbc); + } + + private void addIndentedFormField(JPanel parent, GridBagConstraints gbc, int row, String labelText, JComponent field) { + gbc.gridy = row; + JLabel lbl = new JLabel(labelText); + lbl.setBorder(new EmptyBorder(0, 30, 0, 0)); + gbc.gridx = 0; gbc.weightx = 0.0; gbc.anchor = GridBagConstraints.EAST; + parent.add(lbl, gbc); + gbc.gridx = 1; gbc.weightx = 1.0; gbc.anchor = GridBagConstraints.WEST; + parent.add(field, gbc); + } + + private static int indexOfItem(JComboBox combo, String item) { + DefaultComboBoxModel model = (DefaultComboBoxModel) combo.getModel(); + for (int i = 0; i < model.getSize(); i++) { + if (Objects.equals(item, model.getElementAt(i))) { + return i; + } + } + return -1; + } + + private static class ValueDocumentFilter extends DocumentFilter { + private DataType type = DataType.STRING; + + void setType(DataType type) { + this.type = type; + } + + @Override + public void insertString(FilterBypass fb, int offset, String text, AttributeSet attrs) + throws BadLocationException { + if (matches(fb, offset, 0, text)) { + super.insertString(fb, offset, text, attrs); + } + } + + @Override + public void replace(FilterBypass fb, int offset, int length, String text, AttributeSet attrs) + throws BadLocationException { + if (matches(fb, offset, length, text)) { + super.replace(fb, offset, length, text, attrs); + } + } + + private boolean matches(FilterBypass fb, int offset, int length, String text) { + if (type != DataType.INTEGER && type != DataType.REAL) { + return true; + } + if (text == null) { + return true; + } + try { + String current = fb.getDocument().getText(0, fb.getDocument().getLength()); + String proposed = new StringBuilder(current).replace(offset, offset + length, text).toString(); + return type == DataType.REAL + ? proposed.matches("-?\\d*\\.?\\d*") + : proposed.matches("-?\\d*"); + } catch (BadLocationException ex) { + return false; + } + } + } +} diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/ConfigFactory.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/ConfigFactory.java new file mode 100644 index 000000000..376997d4f --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/ConfigFactory.java @@ -0,0 +1,327 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.plc4x.malbec.s88.plant.panels; + +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.api.S88Enumeration; +import org.apache.plc4x.malbec.s88.core.AddStructEntryUseCase; +import org.apache.plc4x.malbec.s88.core.UpdateStructEntryUseCase; +import org.apache.plc4x.malbec.s88.plant.impl.Plc4xPlantModel; +import org.openide.DialogDisplayer; +import org.openide.NotifyDescriptor; +import org.openide.util.Exceptions; + +import javax.swing.*; +import javax.swing.table.DefaultTableModel; +import java.awt.*; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +public class ConfigFactory { + private ConfigFactory() { + /* This utility class should not be instantiated */ + } + + + public static JPanel createConfigPanel(Plc4xPlantModel model, S88Element element) { + return switch(element.getLevel()){ + case PROCESSCELL -> new JPanel(); + case UNIT -> buildUnitPanel(model, element); + case EQUIPMENTMODULE -> buildEMPanel(model, element); + default -> new JPanel(); + }; + } + + private static JPanel buildUnitPanel(Plc4xPlantModel model, S88Element element) { + String[] columns = {"Name", "Type", "Eng_Units/Enum", "Reference", "StaticValue"}; + DefaultTableModel tableModel = createReadOnlyTableModel(columns); + JTable table = createStandardConfigTable(tableModel); + + updateUnitTableData(element, tableModel); + + table.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if(e.getClickCount() == 2){ + int row = table.rowAtPoint(e.getPoint()); + if (row < 0) { + return; + } + String name = String.valueOf(table.getValueAt(row, 0)); + Map prop = element.getStructuredProperty(name); + + new AttributeDialogBuilder("Edit Attribute") + .withEnumerations(enumerations(model)) + .withEditable(false) + .withInitialData(name, prop) + .onSave((updatedName, updatedProps) -> { + try { + S88Element current = currentElement(model, element); + UpdateStructEntryUseCase.execute(model.getModel(), current, null, updatedName, updatedProps); + model.save(); + } catch (IllegalArgumentException | IllegalStateException ex) { + showError(ex); + } catch (Exception ex) { + Exceptions.printStackTrace(ex); + } + }) + .onUpdate(() -> updateUnitTableData(currentElement(model, element), tableModel)) + .show(); + } + } + }); + + JButton btnAdd = new JButton("Add unit attribute"); + btnAdd.addActionListener(e -> new AttributeDialogBuilder("Create Unit Attribute") + .withEnumerations(enumerations(model)) + .withEditable(true) + .onSave((updatedName, updatedProps) -> { + try { + S88Element current = currentElement(model, element); + AddStructEntryUseCase.execute(model.getModel(), current, null, updatedName, updatedProps); + model.save(); + } catch (IllegalArgumentException | IllegalStateException ex) { + showError(ex); + } catch (Exception ex) { + Exceptions.printStackTrace(ex); + } + }) + .onUpdate(() -> updateUnitTableData(currentElement(model, element), tableModel)) + .show()); + + return new ConfigPanelBuilder(element) + .withInfoPanel() + .withCenterComponent("Unit attributes", new JScrollPane(table)) + .addBottomButton(btnAdd) + .build(); + } + + private static List enumerations(Plc4xPlantModel model) { + if (model != null && model.getModel() != null) { + return model.getModel().getEnumerations(); + } + return Collections.emptyList(); + } + + + private static S88Element currentElement(Plc4xPlantModel model, S88Element element) { + if (model != null && element != null && element.getId() != null) { + S88Element current = model.getElementByID(element.getId()); + if (current != null) { + return current; + } + } + return element; + } + + private static void showError(Exception ex) { + DialogDisplayer.getDefault().notify(new NotifyDescriptor.Message(ex.getMessage(), NotifyDescriptor.ERROR_MESSAGE)); + } + + private static JPanel buildEMPanel(Plc4xPlantModel model, S88Element element) { + String[] paramColumns = {"Name", "Eng_Units/Enum", "Type", "Max", "Min", "Default", "Reference"}; + String[] reportColumns = {"Name", "Eng_Units/Enum", "Type", "Reference"}; + + DefaultTableModel paramsTableModel = createReadOnlyTableModel(paramColumns); + DefaultTableModel reportsTableModel = createReadOnlyTableModel(reportColumns); + + JTable paramsTable = createStandardConfigTable(paramsTableModel); + JTable reportsTable = createStandardConfigTable(reportsTableModel); + + updateEMTableData(element, paramsTableModel, "Parameters"); + updateEMTableData(element, reportsTableModel, "Reports"); + + paramsTable.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if(e.getClickCount() == 2){ + int row = paramsTable.rowAtPoint(e.getPoint()); + if (row < 0) { + return; + } + String name = String.valueOf(paramsTable.getValueAt(row, 0)); + Map params = element.getStructuredProperty("Parameters"); + Map bag = params != null ? (Map) params.get(name) : null; + + new ParameterDialogBuilder("Edit Parameter") + .withEnumerations(enumerations(model)) + .withInitialData(name, bag) + .withEditableFields(false) + .onSave((updatedName, updatedProps) -> { + S88Element current = currentElement(model, element); + try { + UpdateStructEntryUseCase.execute(model.getModel(), current, "Parameters", updatedName, updatedProps); + model.save(); + } catch (IllegalArgumentException | IllegalStateException ex) { + showError(ex); + } catch (Exception ex) { + Exceptions.printStackTrace(ex); + } + updateEMTableData(currentElement(model, element), paramsTableModel, "Parameters"); + }) + .show(); + } + } + }); + + reportsTable.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if(e.getClickCount() == 2){ + int row = reportsTable.rowAtPoint(e.getPoint()); + if (row < 0) { + return; + } + String name = String.valueOf(reportsTable.getValueAt(row, 0)); + Map reports = element.getStructuredProperty("Reports"); + Map bag = reports != null ? (Map) reports.get(name) : null; + + new ParameterDialogBuilder("Edit Report") + .withEnumerations(enumerations(model)) + .reportsMode() + .withInitialData(name, bag) + .withEditableFields(false) + .onSave((updatedName, updatedProps) -> { + S88Element current = currentElement(model, element); + try { + UpdateStructEntryUseCase.execute(model.getModel(), current, "Reports", updatedName, updatedProps); + model.save(); + } catch (IllegalArgumentException | IllegalStateException ex) { + showError(ex); + } catch (Exception ex) { + Exceptions.printStackTrace(ex); + } + updateEMTableData(currentElement(model, element), reportsTableModel, "Reports"); + }) + .show(); + } + } + }); + + JButton btnAddParameter = new JButton("Add parameter"); + btnAddParameter.addActionListener(e -> new ParameterDialogBuilder("Add Parameter") + .withEnumerations(enumerations(model)) + .onSave((updatedName, updatedProps) -> { + S88Element current = currentElement(model, element); + try { + AddStructEntryUseCase.execute(model.getModel(), current, "Parameters", updatedName, updatedProps); + model.save(); + } catch (IllegalArgumentException | IllegalStateException ex) { + showError(ex); + } catch (Exception ex) { + Exceptions.printStackTrace(ex); + } + updateEMTableData(currentElement(model, element), paramsTableModel, "Parameters"); + }) + .show()); + + + JButton btnAddReport = new JButton("Add Report"); + btnAddReport.addActionListener(e -> new ParameterDialogBuilder("Add Report") + .withEnumerations(enumerations(model)) + .reportsMode() + .onSave((updatedName, updatedProps) -> { + S88Element current = currentElement(model, element); + try { + AddStructEntryUseCase.execute(model.getModel(), current, "Reports", updatedName, updatedProps); + model.save(); + } catch (IllegalArgumentException | IllegalStateException ex) { + showError(ex); + } catch (Exception ex) { + Exceptions.printStackTrace(ex); + } + updateEMTableData(currentElement(model, element), reportsTableModel, "Reports"); + }) + .show()); + + + JTabbedPane tabbedPane = new JTabbedPane(); + tabbedPane.addTab("Parameters", new JScrollPane(paramsTable)); + tabbedPane.addTab("Reports", new JScrollPane(reportsTable)); + + + return new ConfigPanelBuilder(element) + .withInfoPanel() + .withCenterComponent(null, tabbedPane) + .addBottomButton(btnAddParameter) + .addBottomButton(btnAddReport) + .build(); + } + + + private static DefaultTableModel createReadOnlyTableModel(String[] columns) { + return new DefaultTableModel(null, columns) { + @Override + public boolean isCellEditable(int row, int column) { + return false; + } + }; + } + + private static JTable createStandardConfigTable(DefaultTableModel model) { + JTable table = new JTable(model); + table.getTableHeader().setReorderingAllowed(false); + table.setShowGrid(true); + table.setGridColor(Color.LIGHT_GRAY); + table.setFillsViewportHeight(true); + return table; + } + + + + private static void updateUnitTableData(S88Element element, DefaultTableModel tableModel) { + tableModel.setRowCount(0); + int columnCount = tableModel.getColumnCount(); + iterate(element, tableModel, columnCount, null ); + } + + private static void updateEMTableData(S88Element element, DefaultTableModel tableModel, String propertyName) { + tableModel.setRowCount(0); + int columnCount = tableModel.getColumnCount(); + + Map params = element.getStructuredProperty(propertyName); + if (params == null) params = Collections.emptyMap(); + + iterate(element, tableModel, columnCount, params); + } + + private static void iterate(S88Element element, DefaultTableModel tableModel, int columnCount, Map property) { + for (var entry : element.getStructuredProperties(property).entrySet()) { + String name = entry.getKey(); + Map propertyValues = entry.getValue(); + + Object[] rowData = new Object[columnCount]; + if (columnCount > 0) { + rowData[0] = name; + } + for (int i = 1; i < columnCount; i++) { + String columnName = tableModel.getColumnName(i); + + Object value = propertyValues.get(columnName); + + rowData[i] = value != null ? value : ""; + } + tableModel.addRow(rowData); + } + } +} diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/ConfigPanelBuilder.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/ConfigPanelBuilder.java new file mode 100644 index 000000000..78b565df6 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/ConfigPanelBuilder.java @@ -0,0 +1,120 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.plc4x.malbec.s88.plant.panels; + +import org.apache.plc4x.malbec.s88.api.S88Element; + +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import javax.swing.border.TitledBorder; +import java.awt.*; + +public class ConfigPanelBuilder { + private final JPanel mainPanel; + private final JPanel centerPanel; + private final JPanel bottomPanel; + private final S88Element element; + + public ConfigPanelBuilder(S88Element element) { + this.element = element; + this.mainPanel = new JPanel(new BorderLayout(5, 10)); + this.mainPanel.setBorder(new EmptyBorder(10, 10, 10, 10)); + + this.centerPanel = new JPanel(new BorderLayout()); + this.bottomPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 10, 10)); + } + + public ConfigPanelBuilder withInfoPanel() { + JPanel optionsPanel = new JPanel(new GridBagLayout()); + optionsPanel.setBorder(BorderFactory.createTitledBorder( + BorderFactory.createLineBorder(Color.LIGHT_GRAY), + "Info", TitledBorder.LEFT, TitledBorder.TOP)); + + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(4, 5, 4, 5); + + JTextField name = new JTextField(element.getId()); + name.setEditable(false); + + JTextField template = new JTextField(element.getElementClass() != null ? element.getElementClass().getName() : ""); + template.setEditable(false); + + JTextField parent = new JTextField(element.getParent() != null ? element.getParent().getId() : ""); + parent.setEditable(false); + + int row = 0; + addFormField(optionsPanel, gbc, row++, "Name:", name); + addFormField(optionsPanel, gbc, row++, "Template:", template); + addFormField(optionsPanel, gbc, row++, "Parent:", parent); + + mainPanel.add(optionsPanel, BorderLayout.NORTH); + return this; + } + + + public ConfigPanelBuilder withCenterComponent(String title, JComponent component) { + JPanel wrapper = new JPanel(new BorderLayout()); + if (title != null && !title.isEmpty()) { + wrapper.setBorder(BorderFactory.createTitledBorder( + BorderFactory.createLineBorder(Color.LIGHT_GRAY), + title, TitledBorder.LEFT, TitledBorder.TOP)); + } + wrapper.add(component, BorderLayout.CENTER); + centerPanel.add(wrapper, BorderLayout.CENTER); + return this; + } + + public ConfigPanelBuilder addBottomButton(JButton button) { + bottomPanel.add(button); + return this; + } + + public JPanel build() { + mainPanel.add(centerPanel, BorderLayout.CENTER); + + if (bottomPanel.getComponentCount() > 0) { + JPanel wrapper = new JPanel(new BorderLayout()); + wrapper.add(mainPanel, BorderLayout.CENTER); + wrapper.add(bottomPanel, BorderLayout.SOUTH); + wrapper.setBorder(new EmptyBorder(5, 5, 5, 5)); + return wrapper; + } + + JPanel finalPanel = new JPanel(new BorderLayout()); + finalPanel.setBorder(new EmptyBorder(5, 5, 5, 5)); + finalPanel.add(mainPanel, BorderLayout.CENTER); + return finalPanel; + } + + private void addFormField(JPanel parentPanel, GridBagConstraints gbc, int row, String labelText, JComponent field) { + gbc.gridy = row; + gbc.gridx = 0; + gbc.weightx = 0.0; + gbc.fill = GridBagConstraints.NONE; + gbc.anchor = GridBagConstraints.EAST; + parentPanel.add(new JLabel(labelText), gbc); + + gbc.gridx = 1; + gbc.weightx = 1.0; + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.anchor = GridBagConstraints.WEST; + parentPanel.add(field, gbc); + } +} \ No newline at end of file diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/EditorTopComponent.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/EditorTopComponent.java new file mode 100644 index 000000000..49cdad0d1 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/EditorTopComponent.java @@ -0,0 +1,138 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.plc4x.malbec.s88.plant.panels; + +import org.apache.plc4x.malbec.s88.api.S88ChangeEvent; +import org.apache.plc4x.malbec.s88.api.S88ChangeListener; +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.plant.impl.Plc4xPlantModel; +import org.openide.windows.TopComponent; +import org.openide.util.NbBundle.Messages; + +import javax.swing.*; +import java.awt.*; + +/** + * Multi-instance editor for S88 Plant Elements. + */ +@TopComponent.Description( + preferredID = "EditorTopComponent", + persistenceType = TopComponent.PERSISTENCE_NEVER +) +@TopComponent.Registration(mode = "editor", openAtStartup = false) +@Messages({ + "CTL_EditorTopComponent=Editor Window", +}) +public final class EditorTopComponent extends TopComponent implements S88ChangeListener { + + private S88Element element; + private Plc4xPlantModel model; + + public EditorTopComponent() { + initComponents(); + setName(Bundle.CTL_EditorTopComponent()); + } + + public EditorTopComponent(Plc4xPlantModel model, S88Element element) { + this(); + this.model = model; + this.element = element; + updateTitle(); + + if (model != null && model.getModel() != null) { + model.getModel().addChangeListener(this); + } + + setLayout(new BorderLayout()); + + JPanel panel = ConfigFactory.createConfigPanel(model, element); + add(panel, BorderLayout.CENTER); + } + + private void updateTitle() { + if (element != null) { + setName(element.getId() + " [" + element.getLevel().getDisplayName() + "]"); + } + } + + @Override + public void onS88Change(S88ChangeEvent event) { + if (event.element().equals(element) || + (event.type() == S88ChangeEvent.Type.RELOADED)) { + java.awt.EventQueue.invokeLater(this::updateTitle); + } + } + + @Override + public void componentClosed() { + if (model != null && model.getModel() != null) { + model.getModel().removeChangeListener(this); + } + } + + public S88Element getElement() { + return element; + } + + @Override + public int getPersistenceType() { + return TopComponent.PERSISTENCE_NEVER; + } + + /** + * This method is called from within the constructor to initialize the form. + * WARNING: Do NOT modify this code. The content of this method is always + * regenerated by the Form Editor. + */ + // //GEN-BEGIN:initComponents + private void initComponents() { + + javax.swing.GroupLayout layout = new javax.swing.GroupLayout(this); + this.setLayout(layout); + layout.setHorizontalGroup( + layout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING) + .addGap(0, 400, Short.MAX_VALUE) + ); + layout.setVerticalGroup( + layout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING) + .addGap(0, 300, Short.MAX_VALUE) + ); + }// //GEN-END:initComponents + + // Variables declaration - do not modify//GEN-BEGIN:variables + // End of variables declaration//GEN-END:variables + @Override + public void componentOpened() { + // TODO add custom code on component opening + } + + + void writeProperties(java.util.Properties p) { + // better to version settings since initial version as advocated at + // http://wiki.apidesign.org/wiki/PropertyFiles + p.setProperty("version", "1.0"); + // TODO store your settings + } + + void readProperties(java.util.Properties p) { + String version = p.getProperty("version"); + // TODO read your settings according to their version + } +} diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/EnumerationDialogBuilder.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/EnumerationDialogBuilder.java new file mode 100644 index 000000000..34abfe0c0 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/EnumerationDialogBuilder.java @@ -0,0 +1,147 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.plc4x.malbec.s88.plant.panels; + +import javax.swing.*; +import javax.swing.table.DefaultTableModel; +import java.awt.*; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.BiConsumer; + +public class EnumerationDialogBuilder { + + private final String title; + private boolean isEditMode = false; + private String initialName = ""; + private Map initialValues = new LinkedHashMap<>(); + + private BiConsumer> onSaveAction; + + public EnumerationDialogBuilder(String title) { + this.title = title; + } + + public EnumerationDialogBuilder withInitialData(String name, Map values) { + this.isEditMode = true; + this.initialName = name; + if (values != null) { + this.initialValues = values; + } + return this; + } + + public EnumerationDialogBuilder onSave(BiConsumer> action) { + this.onSaveAction = action; + return this; + } + + public void show() { + JDialog dialog = new JDialog(); + dialog.setTitle(title); + dialog.setModal(true); + dialog.setResizable(false); + dialog.setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE); + + JPanel contentPane = new JPanel(new BorderLayout(10, 10)); + contentPane.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); + + JTextField txtName = new JTextField(initialName, 20); + + DefaultTableModel tableModel = new DefaultTableModel(new String[]{"Label", "Index"}, 0); + for (var entry : initialValues.entrySet()) { + tableModel.addRow(new Object[]{entry.getKey(), entry.getValue()}); + } + JTable table = new JTable(tableModel); + + JPanel top = new JPanel(new FlowLayout(FlowLayout.LEFT, 5, 5)); + top.add(new JLabel("Enumeration Set Name:")); + top.add(txtName); + + JPanel btnPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 5, 5)); + JButton btnAdd = new JButton("Add"); + JButton btnRemove = new JButton("Remove"); + btnAdd.addActionListener(e -> tableModel.addRow(new Object[]{"", 0})); + btnRemove.addActionListener(e -> { + int row = table.getSelectedRow(); + if (row >= 0) { + tableModel.removeRow(row); + } + }); + btnPanel.add(btnAdd); + btnPanel.add(btnRemove); + + JPanel center = new JPanel(new BorderLayout(0, 5)); + center.add(btnPanel, BorderLayout.NORTH); + center.add(new JScrollPane(table), BorderLayout.CENTER); + + JPanel actions = new JPanel(new FlowLayout(FlowLayout.RIGHT, 5, 5)); + JButton btnOk = new JButton("OK"); + JButton btnCancel = new JButton("Cancel"); + btnCancel.addActionListener(e -> dialog.dispose()); + btnOk.addActionListener(e -> { + String name = txtName.getText().trim(); + if (name.isEmpty()) { + JOptionPane.showMessageDialog(dialog, "Enumeration set name cannot be empty.", + "Error", JOptionPane.ERROR_MESSAGE); + return; + } + Map values = new LinkedHashMap<>(); + for (int i = 0; i < tableModel.getRowCount(); i++) { + String label = String.valueOf(tableModel.getValueAt(i, 0)).trim(); + if (label.isEmpty()) { + continue; + } + Object idx = tableModel.getValueAt(i, 1); + int index; + if (idx == null) { + index = 0; + } else if (idx instanceof Number n) { + index = n.intValue(); + } else { + try { + index = Integer.parseInt(String.valueOf(idx).trim()); + } catch (NumberFormatException ex) { + JOptionPane.showMessageDialog(dialog, + "Index for '" + label + "' must be a number.", + "Error", JOptionPane.ERROR_MESSAGE); + return; + } + } + values.put(label, index); + } + if (onSaveAction != null) { + onSaveAction.accept(name, values); + } + dialog.dispose(); + }); + actions.add(btnOk); + actions.add(btnCancel); + + contentPane.add(top, BorderLayout.NORTH); + contentPane.add(center, BorderLayout.CENTER); + contentPane.add(actions, BorderLayout.SOUTH); + + dialog.setContentPane(contentPane); + dialog.pack(); + dialog.setLocationRelativeTo(null); + dialog.setVisible(true); + } +} diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/ExportDialog.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/ExportDialog.java new file mode 100644 index 000000000..29b67a647 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/ExportDialog.java @@ -0,0 +1,261 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.plc4x.malbec.s88.plant.panels; + +import org.apache.plc4x.malbec.s88.plant.impl.Plc4xPlantModel; +import org.netbeans.api.project.Project; +import org.openide.filesystems.FileUtil; + +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import java.awt.*; +import java.awt.event.ActionEvent; +import java.io.File; + +public class ExportDialog extends JDialog{ + + + + private final Project[] projects; + private JList projectList; + private JTextField txtDirectory; + private JTextField txtFileName; + + public ExportDialog(Project[] projects) { + this.projects = projects; + + + setTitle("Export Physical Model"); + setModal(true); + setSize(1000, 400); + setLocationRelativeTo(null); + setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE); + + JPanel mainPanel = new JPanel(new BorderLayout(10, 10)); + mainPanel.setBorder(new EmptyBorder(10, 10, 10, 10)); + + + mainPanel.add(createTopPanel(), BorderLayout.NORTH); + mainPanel.add(createMiddlePanel(), BorderLayout.CENTER); + mainPanel.add(createBottomPanel(), BorderLayout.SOUTH); + + add(mainPanel); + + + if (projects.length > 0) { + projectList.setSelectedIndex(0); + } + } + + private JPanel createTopPanel() { + JPanel topPanel = new JPanel(new GridBagLayout()); + topPanel.setBorder(BorderFactory.createTitledBorder("Export to:")); + + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(5, 5, 5, 5); + gbc.fill = GridBagConstraints.HORIZONTAL; + + JPanel radioPanel = new JPanel(); + radioPanel.setLayout(new BoxLayout(radioPanel, BoxLayout.Y_AXIS)); + JRadioButton rbJson = new JRadioButton("JSON Files ??"); + rbJson.setEnabled(false); + JRadioButton rbB2mml = new JRadioButton("XML Files (B2MML)"); + rbB2mml.setEnabled(false); + JRadioButton rbXml = new JRadioButton("AXML Files (Rockwell)", true); + + ButtonGroup group = new ButtonGroup(); + group.add(rbJson); + group.add(rbB2mml); + group.add(rbXml); + + radioPanel.add(rbJson); + radioPanel.add(rbB2mml); + radioPanel.add(rbXml); + + gbc.gridx = 0; + gbc.gridy = 0; + gbc.gridheight = 3; + gbc.weightx = 0.0; + topPanel.add(radioPanel, gbc); + + + gbc.gridx = 1; gbc.gridy = 0; gbc.weightx = 0; + topPanel.add(new JLabel("Target Directory:"), gbc); + + gbc.gridx = 2; gbc.weightx = 1.0; + + String defaultDir = System.getProperty("user.home"); + + txtDirectory = new JTextField(defaultDir); + topPanel.add(txtDirectory, gbc); + + gbc.gridx = 3; gbc.weightx = 0; + JButton btnBrowse = new JButton("..."); + + + btnBrowse.addActionListener(e -> { + JFileChooser chooser = new JFileChooser(txtDirectory.getText()); + chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + if (chooser.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) { + txtDirectory.setText(chooser.getSelectedFile().getAbsolutePath()); + } + }); + topPanel.add(btnBrowse, gbc); + + return topPanel; + } + + private JPanel createMiddlePanel() { + JPanel middlePanel = new JPanel(new BorderLayout(10, 10)); + + JPanel leftPanel = new JPanel(new BorderLayout(5, 5)); + leftPanel.add(new JLabel("Select a model"), BorderLayout.NORTH); + + projectList = new JList<>(projects); + projectList.setCellRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent(JList list, Object value, + int index, boolean isSelected, boolean cellHasFocus) { + super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + if (value instanceof Project) { + setText(((Project) value).getProjectDirectory().getName()); + } + return this; + } + }); + projectList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + + projectList.addListSelectionListener(e -> { + if (!e.getValueIsAdjusting() && projectList.getSelectedValue() != null) { + Project p = projectList.getSelectedValue(); + txtFileName.setText(p.getProjectDirectory().getName() + ".axml"); + } + }); + + JScrollPane listScrollPane = new JScrollPane(projectList); + listScrollPane.setPreferredSize(new Dimension(250, 0)); + leftPanel.add(listScrollPane, BorderLayout.CENTER); + middlePanel.add(leftPanel, BorderLayout.WEST); + + JTabbedPane tabbedPane = new JTabbedPane(); + tabbedPane.addTab("Model Details", createDetailsTab()); + middlePanel.add(tabbedPane, BorderLayout.CENTER); + + return middlePanel; + } + + private JScrollPane createDetailsTab() { + JPanel panel = new JPanel(new GridBagLayout()); + panel.setBorder(new EmptyBorder(10, 10, 10, 10)); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(4, 4, 4, 4); + gbc.fill = GridBagConstraints.HORIZONTAL; + + int row = 0; + + // metadata from ISA-88 project here + addFormRow(panel, gbc, row++, "Version Number", createUneditableField("1.0")); + addFormRow(panel, gbc, row++, "Model Type", createUneditableField("MALBEC")); + + + txtFileName = createUneditableField(""); + addFormRow(panel, gbc, row++, "Output File Name", txtFileName); + + JScrollPane scrollPane = new JScrollPane(panel); + scrollPane.getVerticalScrollBar().setUnitIncrement(16); + return scrollPane; + } + + private void addFormRow(JPanel panel, GridBagConstraints gbc, int row, String labelText, JComponent field) { + gbc.gridy = row; + gbc.gridx = 0; gbc.weightx = 0.0; gbc.anchor = GridBagConstraints.EAST; + panel.add(new JLabel(labelText + " ", SwingConstants.RIGHT), gbc); + + gbc.gridx = 1; gbc.weightx = 1.0; gbc.anchor = GridBagConstraints.WEST; + panel.add(field, gbc); + } + + private JTextField createUneditableField(String text) { + JTextField tf = new JTextField(text); + tf.setEditable(false); + tf.setBackground(new Color(240, 240, 240)); + return tf; + } + + private JPanel createBottomPanel() { + JPanel bottomPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 10, 5)); + + JButton btnExport = new JButton("Export"); + btnExport.setPreferredSize(new Dimension(100, 26)); + + + btnExport.addActionListener(this::actionPerformed); + + JButton btnCancel = new JButton("Cancel"); + btnCancel.setPreferredSize(new Dimension(100, 26)); + btnCancel.addActionListener(e -> dispose()); + + bottomPanel.add(btnExport); + bottomPanel.add(btnCancel); + + return bottomPanel; + } + + private void actionPerformed(ActionEvent e) { + Project selected = projectList.getSelectedValue(); + if (selected == null) { + JOptionPane.showMessageDialog(this, "Please select a project to export.", "Warning", JOptionPane.WARNING_MESSAGE); + return; + } + + String exportDir = txtDirectory.getText(); + + + try { + File destFile = new File(exportDir, selected.getProjectDirectory().getName() + ".axml"); + + if (destFile.exists()) { + int response = JOptionPane.showConfirmDialog( + this, + "File '" + destFile.getName() + "' already exist in this location.\nDo you want to overwrite it?", + "Confirm overwritting", + JOptionPane.YES_NO_OPTION, + JOptionPane.WARNING_MESSAGE + ); + + if (response != JOptionPane.YES_OPTION) { + return; + } + } + + Plc4xPlantModel pmodel = selected.getLookup().lookup(Plc4xPlantModel.class); + if (pmodel != null) { + pmodel.export(destFile.getAbsolutePath()); + JOptionPane.showMessageDialog(this, "Export successful!\nFile: " + destFile.getName(), "Success", JOptionPane.INFORMATION_MESSAGE); + dispose(); + } else { + JOptionPane.showMessageDialog(this, "Plc4xPlantModel not found in the selected project.", "Error", JOptionPane.ERROR_MESSAGE); + } + } catch (Exception ex) { + JOptionPane.showMessageDialog(this, "Export failed: " + ex.getMessage(), "Error", JOptionPane.ERROR_MESSAGE); + ex.printStackTrace(); + } + } +} diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/NewElementDialog.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/NewElementDialog.java new file mode 100644 index 000000000..9dc54c8b9 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/NewElementDialog.java @@ -0,0 +1,237 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.plc4x.malbec.s88.plant.panels; + +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.api.S88ElementClass; +import org.apache.plc4x.malbec.s88.api.S88Level; +import org.apache.plc4x.malbec.s88.core.CreateElementUseCase; +import org.apache.plc4x.malbec.s88.plant.impl.Plc4xPlantModel; +import org.openide.DialogDisplayer; +import org.openide.NotifyDescriptor; +import org.openide.util.Exceptions; + +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.List; +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import java.awt.*; +import java.awt.event.ActionEvent; + +public class NewElementDialog extends JDialog{ + + private final JList classList; + private final DefaultListModel classListModel; + private final Plc4xPlantModel model; + private final S88Element parent; + private JTextField txtClass; + private JTextField IDField; + + public NewElementDialog(Plc4xPlantModel model, S88Element parent, List definedClasses) { + + this.classListModel = new DefaultListModel<>(); + for(S88ElementClass ec : definedClasses){ + this.classListModel.addElement(ec); + } + + classList = new JList<>(classListModel); + + this.parent = parent; + this.model = model; + + setTitle("Create New Element"); + setModal(true); + setSize(1000, 300); + setLocationRelativeTo(null); + setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE); + + JPanel mainPanel = new JPanel(new BorderLayout(10, 10)); + mainPanel.setBorder(new EmptyBorder(10, 10, 10, 10)); + + + + mainPanel.add(createTopPanel(), BorderLayout.NORTH); + mainPanel.add(createBottomPanel(), BorderLayout.SOUTH); + + add(mainPanel); + + + if (!definedClasses.isEmpty()) { + classList.setSelectedIndex(0); + } + } + + private JPanel createTopPanel() { + JPanel middlePanel = new JPanel(new BorderLayout(10, 10)); + + JPanel leftPanel = new JPanel(new BorderLayout(5, 5)); + + JPanel headerTemplatesPanel = new JPanel(new BorderLayout()); + headerTemplatesPanel.add(new JLabel("Templates"), BorderLayout.WEST); + + JButton btnNewTemplate = new JButton("New Template"); + + if(parent.getLevel() == S88Level.EQUIPMENTMODULE) btnNewTemplate.setEnabled(false); + + btnNewTemplate.addActionListener(e -> { + int sizeBefore = parent.getElementClasses().size(); + TemplateFactory.createDialog(parent, model, this); + + List updated = parent.getElementClasses(); + if(updated.size() > sizeBefore){ + classListModel.addElement(updated.getLast()); + } + + }); + headerTemplatesPanel.add(btnNewTemplate, BorderLayout.EAST); + + + leftPanel.add(headerTemplatesPanel, BorderLayout.NORTH); + + classList.setCellRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent(JList list, Object value, + int index, boolean isSelected, boolean cellHasFocus) { + super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + if (value instanceof S88ElementClass) { + setText(((S88ElementClass) value).getName()); + } + return this; + } + }); + classList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + + classList.addListSelectionListener(e -> { + if (!e.getValueIsAdjusting() && classList.getSelectedValue() != null) { + S88ElementClass ec = classList.getSelectedValue(); + txtClass.setText(ec.getName().toUpperCase()); + } + }); + + classList.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + + if (e.getClickCount() == 2) { + int index = classList.locationToIndex(e.getPoint()); + if (index >= 0 && classList.getCellBounds(index, index).contains(e.getPoint())) { + S88ElementClass ec = classList.getModel().getElementAt(index); + TemplateFactory.showTemplate(ec); + } + } + } + }); + + JScrollPane listScrollPane = new JScrollPane(classList); + listScrollPane.setPreferredSize(new Dimension(250, 0)); + leftPanel.add(listScrollPane, BorderLayout.CENTER); + middlePanel.add(leftPanel, BorderLayout.WEST); + + JTabbedPane tabbedPane = new JTabbedPane(); + tabbedPane.addTab("General", createDetailsTab()); + middlePanel.add(tabbedPane, BorderLayout.CENTER); + + return middlePanel; + } + + private JScrollPane createDetailsTab() { + JPanel panel = new JPanel(new GridBagLayout()); + panel.setBorder(new EmptyBorder(10, 10, 10, 10)); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(4, 4, 4, 4); + gbc.fill = GridBagConstraints.HORIZONTAL; + + int row = 0; + + S88Level childLevel = parent.getLevel() != null ? parent.getLevel().getChildLevel() : null; + IDField = createField("", true); + addFormRow(panel, gbc, row++, "Element ID", IDField); + addFormRow(panel, gbc, row++, "Level", createField(childLevel != null ? childLevel.toString() : "", false)); + + + txtClass = createField("", false); + addFormRow(panel, gbc, row++, "Template", txtClass); + + JScrollPane scrollPane = new JScrollPane(panel); + scrollPane.getVerticalScrollBar().setUnitIncrement(16); + return scrollPane; + } + + private void addFormRow(JPanel panel, GridBagConstraints gbc, int row, String labelText, JComponent field) { + gbc.gridy = row; + gbc.gridx = 0; gbc.weightx = 0.0; gbc.anchor = GridBagConstraints.EAST; + panel.add(new JLabel(labelText + " ", SwingConstants.RIGHT), gbc); + + gbc.gridx = 1; gbc.weightx = 1.0; gbc.anchor = GridBagConstraints.WEST; + panel.add(field, gbc); + } + + private JTextField createField(String text, Boolean editable) { + JTextField tf = new JTextField(text); + tf.setEditable(editable); + tf.setBackground(new Color(240, 240, 240)); + return tf; + } + + private JPanel createBottomPanel() { + JPanel bottomPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 10, 5)); + + JButton btnCreate = new JButton("Create"); + btnCreate.setPreferredSize(new Dimension(100, 26)); + + if (parent.getLevel() == null || parent.getLevel().getChildLevel() == null) { + btnCreate.setEnabled(false); + btnCreate.setToolTipText("Cannot create an element under a leaf level."); + } + + + btnCreate.addActionListener(this::actionPerformed); + + JButton btnCancel = new JButton("Cancel"); + btnCancel.setPreferredSize(new Dimension(100, 26)); + btnCancel.addActionListener(e -> dispose()); + + bottomPanel.add(btnCreate); + bottomPanel.add(btnCancel); + + return bottomPanel; + } + + private void actionPerformed(ActionEvent e) { + S88ElementClass selected = classList.getSelectedValue(); + if (selected == null) { + JOptionPane.showMessageDialog(this, "Please select a template for the new Element.", "Warning", JOptionPane.WARNING_MESSAGE); + return; + } + + + try { + S88Element currentParent = model.getModel().findById(parent.getId()).orElse(parent); + CreateElementUseCase.execute(model.getModel(), currentParent, IDField.getText(), selected); + model.save(); + dispose(); + } catch (IllegalArgumentException | IllegalStateException ex) { + DialogDisplayer.getDefault().notify(new NotifyDescriptor.Message(ex.getMessage(), NotifyDescriptor.ERROR_MESSAGE)); + } catch (Exception ex) { + Exceptions.printStackTrace(ex); + } + } +} diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/ParameterDialogBuilder.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/ParameterDialogBuilder.java new file mode 100644 index 000000000..1459a6c05 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/ParameterDialogBuilder.java @@ -0,0 +1,455 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.plc4x.malbec.s88.plant.panels; + +import org.apache.plc4x.malbec.s88.api.DataType; +import org.apache.plc4x.malbec.s88.api.EngineeringUnits; +import org.apache.plc4x.malbec.s88.api.S88Enumeration; +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import javax.swing.text.AttributeSet; +import javax.swing.text.BadLocationException; +import javax.swing.text.DocumentFilter; +import javax.swing.text.PlainDocument; +import java.awt.*; +import java.awt.event.ItemEvent; +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.BiConsumer; + +public class ParameterDialogBuilder { + + private final String title; + private boolean isEditMode = false; + private String initialName = ""; + private Map initialProps = null; + private List enumerations = new ArrayList<>(); + private boolean reportsMode = false; + private Window owner; + private boolean isEditable = true; + private BiConsumer> onSaveAction; + protected JTextField txtName; + protected JComboBox comboType; + protected JComboBox comboEnumeration; + protected JComboBox comboEngineeringUnits; + protected JTextField txtMax; + protected JTextField txtMin; + protected JTextField txtDefault; + protected JComboBox comboDefault; + protected JPanel defaultField; + protected JTextField txtReference; + protected boolean hasReference = true; + protected JButton btnOk; + protected JButton btnCancel; + + private final ValueDocumentFilter valueFilter = new ValueDocumentFilter(); + + public ParameterDialogBuilder(String title) { + this.title = title; + } + + public ParameterDialogBuilder reportsMode() { + this.reportsMode = true; + return this; + } + + public ParameterDialogBuilder withOwner(Window owner) { + this.owner = owner; + return this; + } + + public ParameterDialogBuilder withEnumerations(List enumerations) { + this.enumerations = enumerations != null ? new ArrayList<>(enumerations) : new ArrayList<>(); + return this; + } + + public ParameterDialogBuilder withInitialData(String name, Map props) { + this.isEditMode = true; + this.initialName = name; + this.initialProps = props; + return this; + } + + public ParameterDialogBuilder onSave(BiConsumer> action) { + this.onSaveAction = action; + return this; + } + + public ParameterDialogBuilder withEditableFields(boolean editable){ + this.isEditable = editable; + return this; + } + + public void show() { + JDialog dialog = new JDialog(owner, title, Dialog.ModalityType.APPLICATION_MODAL); + dialog.setResizable(false); + dialog.setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE); + + createWidgets(); + + JPanel topSection = new JPanel(new BorderLayout(15, 0)); + GridBagConstraints gbc = new GridBagConstraints(); + gbc.fill = GridBagConstraints.HORIZONTAL; + gbc.insets = new Insets(3, 5, 3, 5); + gbc.weightx = 1.0; + topSection.add(buildFormPanel(gbc), BorderLayout.CENTER); + topSection.add(buildButtonPanel(), BorderLayout.EAST); + + wireToggleListeners(); + applyInitialData(); + toggleTypeFields(); + applyStoredDefault(); + + btnCancel.addActionListener(e -> dialog.dispose()); + + btnOk.addActionListener(e -> { + if (onSaveAction != null) { + if (!validateName(dialog)) { + return; + } + Map parameterBag = buildParameterBag(); + onSaveAction.accept(txtName.getText().trim(), parameterBag); + } + dialog.dispose(); + }); + + JPanel contentPane = new JPanel(new BorderLayout(10, 10)); + contentPane.setBorder(new EmptyBorder(10, 10, 10, 10)); + contentPane.add(topSection, BorderLayout.NORTH); + + dialog.setContentPane(contentPane); + dialog.pack(); + dialog.setLocationRelativeTo(null); + dialog.setVisible(true); + } + + protected void createWidgets() { + txtName = new JTextField(); + comboType = new JComboBox<>(DataType.displayNames()); + comboEnumeration = new JComboBox<>(); + comboEngineeringUnits = new JComboBox<>(); + + + txtName.setEnabled(isEditable); + comboType.setEnabled(isEditable); + comboEnumeration.setEnabled(isEditable); + comboEngineeringUnits.setEnabled(isEditable); + + + txtMax = new JTextField(); + ((PlainDocument) txtMax.getDocument()).setDocumentFilter(valueFilter); + txtMin = new JTextField(); + ((PlainDocument) txtMin.getDocument()).setDocumentFilter(valueFilter); + txtDefault = new JTextField(); + ((PlainDocument) txtDefault.getDocument()).setDocumentFilter(valueFilter); + comboDefault = new JComboBox<>(); + + txtReference = new JTextField(); + txtReference.addKeyListener(new KeyAdapter() { + @Override + public void keyTyped(KeyEvent e){ + char c = e.getKeyChar(); + if (!Character.isDigit(c) && c != KeyEvent.VK_BACK_SPACE) { + e.consume(); + } + } + }); + + defaultField = new JPanel(new CardLayout()); + defaultField.add(txtDefault, "TEXT"); + defaultField.add(comboDefault, "COMBO"); + + for (S88Enumeration enumeration : enumerations) { + if (enumeration.getName() != null) { + comboEnumeration.addItem(enumeration.getName()); + } + } + + for (EngineeringUnits e : EngineeringUnits.values()) { + comboEngineeringUnits.addItem(e); + } + + comboEngineeringUnits.setRenderer(new DefaultListCellRenderer() { + @Override + public Component getListCellRendererComponent(JList list, Object value, + int index, boolean isSelected, boolean cellHasFocus) { + super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + if (value instanceof EngineeringUnits) { + setText(((EngineeringUnits) value).name()); + } + return this; + } + }); + + + + } + + protected boolean showRangeFields() { + return !reportsMode; + } + + + protected JPanel buildFormPanel(GridBagConstraints gbc) { + JPanel formPanel = new JPanel(new GridBagLayout()); + int row = 0; + addFormField(formPanel, gbc, row++, "Name", txtName); + addFormField(formPanel, gbc, row++, "Type", comboType); + addFormField(formPanel, gbc, row++, "Enumeration", comboEnumeration); + addFormField(formPanel, gbc, row++, "Engineering Unit", comboEngineeringUnits); + + if (showRangeFields()) { + addFormField(formPanel, gbc, row++, "Max", txtMax); + addFormField(formPanel, gbc, row++, "Min", txtMin); + addFormField(formPanel, gbc, row++, "Default", defaultField); + } + if(hasReference) { + addFormField(formPanel, gbc, row++, "Reference", txtReference); + } + + return formPanel; + } + + protected ParameterDialogBuilder withReference(boolean val){ + this.hasReference = val; + return this; + } + + protected JPanel buildButtonPanel() { + JPanel buttonPanel = new JPanel(); + buttonPanel.setLayout(new BoxLayout(buttonPanel, BoxLayout.Y_AXIS)); + btnOk = new JButton("OK"); + btnCancel = new JButton("Cancel"); + Dimension btnSize = new Dimension(80, 26); + btnOk.setMaximumSize(btnSize); + btnCancel.setMaximumSize(btnSize); + buttonPanel.add(btnOk); + buttonPanel.add(Box.createRigidArea(new Dimension(0, 10))); + buttonPanel.add(btnCancel); + return buttonPanel; + } + + protected void refreshDefaultCombo() { + String previous = Objects.toString(comboDefault.getSelectedItem(), ""); + comboDefault.removeAllItems(); + String enumName = Objects.toString(comboEnumeration.getSelectedItem(), ""); + for (S88Enumeration enumeration : enumerations) { + if (enumName.equals(enumeration.getName())) { + for (String label : enumeration.getValues().keySet()) { + comboDefault.addItem(label); + } + break; + } + } + if (!previous.isEmpty()) { + comboDefault.setSelectedItem(previous); + } + } + + protected void toggleTypeFields() { + boolean isEnumeration = DataType.isEnumeration(Objects.toString(comboType.getSelectedItem(), "")); + if(isEditable) { + comboEnumeration.setEnabled(isEnumeration); + comboEngineeringUnits.setEnabled(!isEnumeration); + } + applyValueInputFilter(); + if (!showRangeFields()) { + return; + } + txtMax.setEnabled(!isEnumeration); + txtMin.setEnabled(!isEnumeration); + CardLayout cl = (CardLayout) defaultField.getLayout(); + cl.show(defaultField, isEnumeration ? "COMBO" : "TEXT"); + if (isEnumeration) { + refreshDefaultCombo(); + } + } + + + protected void wireToggleListeners() { + comboType.addItemListener(e -> { + if (e.getStateChange() == ItemEvent.SELECTED) { + toggleTypeFields(); + } + }); + + comboEnumeration.addItemListener(e -> { + if (showRangeFields() && e.getStateChange() == ItemEvent.SELECTED) { + refreshDefaultCombo(); + } + }); + + + } + + protected void applyInitialData() { + if (isEditMode && initialProps != null) { + txtName.setText(initialName); + txtName.setEditable(false); + if (initialProps.get("Type") != null) { + comboType.setSelectedItem(String.valueOf(initialProps.get("Type")).trim().toUpperCase()); + } + if(initialProps.get("Reference") != null) { + txtReference.setText(String.valueOf(initialProps.get("Reference"))); + } + boolean isEnumeration = DataType.isEnumeration(Objects.toString(comboType.getSelectedItem(), "")); + if (isEnumeration) { + comboEnumeration.setSelectedItem(Objects.toString(initialProps.get("Eng_Units/Enum"), "")); + } else { + comboEngineeringUnits.setSelectedItem(EngineeringUnits.fromName(Objects.toString(initialProps.get("Eng_Units/Enum"), ""))); + if (showRangeFields()) { + txtMax.setText(Objects.toString(initialProps.get("Max"), "")); + txtMin.setText(Objects.toString(initialProps.get("Min"), "")); + txtDefault.setText(Objects.toString(initialProps.get("Default"), "")); + } + } + } + } + + protected void applyStoredDefault() { + if (showRangeFields() && isEditMode && initialProps != null + && DataType.isEnumeration(Objects.toString(comboType.getSelectedItem(), ""))) { + String storedDefault = Objects.toString(initialProps.get("Default"), ""); + if (!storedDefault.isEmpty() && indexOfItem(comboDefault, storedDefault) >= 0) { + comboDefault.setSelectedItem(storedDefault); + } + } + } + + + + + protected boolean validateName(JDialog dialog) { + String name = txtName.getText().trim(); + if (name.isEmpty()) { + JOptionPane.showMessageDialog(dialog, "Name cannot be empty.", + "Error", JOptionPane.ERROR_MESSAGE); + return false; + } + return true; + } + + protected Map buildParameterBag() { + boolean isEnumeration = DataType.isEnumeration(Objects.toString(comboType.getSelectedItem(), "")); + Map parameterBag = isEditMode && initialProps != null + ? new LinkedHashMap<>(initialProps) : new LinkedHashMap<>(); + parameterBag.put("Type", Objects.toString(comboType.getSelectedItem(), "")); + + if (isEnumeration) { + parameterBag.put("Eng_Units/Enum", Objects.toString(comboEnumeration.getSelectedItem(), "")); + if (showRangeFields()) { + parameterBag.put("Default", Objects.toString(comboDefault.getSelectedItem(), "")); + parameterBag.put("Max", ""); + parameterBag.put("Min", ""); + } + } else { + parameterBag.put("Eng_Units/Enum", comboEngineeringUnits.getSelectedItem() != null + ? ((EngineeringUnits) comboEngineeringUnits.getSelectedItem()).getName() + : ""); + if (showRangeFields()) { + parameterBag.put("Default", txtDefault.getText()); + parameterBag.put("Max", txtMax.getText()); + parameterBag.put("Min", txtMin.getText()); + } + } + parameterBag.put("Reference", safeReference(txtReference.getText())); + return parameterBag; + } + + private static int safeReference(String text) { + if (text == null || text.trim().isEmpty()) { + return 0; + } + try { + return Integer.parseInt(text.trim()); + } catch (NumberFormatException e) { + return 0; + } + } + + private void addFormField(JPanel parent, GridBagConstraints gbc, int row, String labelText, JComponent field) { + gbc.gridy = row; + gbc.gridx = 0; gbc.weightx = 0.0; gbc.anchor = GridBagConstraints.EAST; + parent.add(new JLabel(labelText), gbc); + gbc.gridx = 1; gbc.weightx = 1.0; gbc.anchor = GridBagConstraints.WEST; + parent.add(field, gbc); + } + + private static int indexOfItem(JComboBox combo, String item) { + DefaultComboBoxModel model = (DefaultComboBoxModel) combo.getModel(); + for (int i = 0; i < model.getSize(); i++) { + if (Objects.equals(item, model.getElementAt(i))) { + return i; + } + } + return -1; + } + + protected void applyValueInputFilter() { + valueFilter.setType(DataType.fromString(Objects.toString(comboType.getSelectedItem(), ""))); + } + + private static class ValueDocumentFilter extends DocumentFilter { + private DataType type = DataType.STRING; + + void setType(DataType type) { + this.type = type; + } + + @Override + public void insertString(FilterBypass fb, int offset, String text, AttributeSet attrs) + throws BadLocationException { + if (matches(fb, offset, 0, text)) { + super.insertString(fb, offset, text, attrs); + } + } + + @Override + public void replace(FilterBypass fb, int offset, int length, String text, AttributeSet attrs) + throws BadLocationException { + if (matches(fb, offset, length, text)) { + super.replace(fb, offset, length, text, attrs); + } + } + + private boolean matches(FilterBypass fb, int offset, int length, String text) { + if (type != DataType.INTEGER && type != DataType.REAL) { + return true; + } + if (text == null) { + return true; + } + try { + String current = fb.getDocument().getText(0, fb.getDocument().getLength()); + String proposed = new StringBuilder(current).replace(offset, offset + length, text).toString(); + return type == DataType.REAL + ? proposed.matches("-?\\d*\\.?\\d*") + : proposed.matches("-?\\d*"); + } catch (BadLocationException ex) { + return false; + } + } + } +} \ No newline at end of file diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/PropertiesDialogBuilder.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/PropertiesDialogBuilder.java new file mode 100644 index 000000000..39e490b74 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/PropertiesDialogBuilder.java @@ -0,0 +1,187 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.plc4x.malbec.s88.plant.panels; + +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import java.awt.*; + +public class PropertiesDialogBuilder { + private final JDialog dialog; + private final JTabbedPane tabbedPane; + + + private Runnable onOkAction; + private Runnable onApplyAction; + + public PropertiesDialogBuilder(String title) { + dialog = new JDialog(); + dialog.setTitle(title); + dialog.setModal(true); + dialog.setSize(600, 420); + dialog.setLocationRelativeTo(null); + dialog.setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE); + + JPanel mainPanel = new JPanel(new BorderLayout(5, 5)); + mainPanel.setBorder(new EmptyBorder(5, 5, 5, 5)); + + tabbedPane = new JTabbedPane(); + mainPanel.add(tabbedPane, BorderLayout.CENTER); + + dialog.setContentPane(mainPanel); + } + + public TabBuilder beginTab(String tabTitle) { + return new TabBuilder(this, tabTitle); + } + + + public PropertiesDialogBuilder addEmptyTab(String tabTitle) { + tabbedPane.addTab(tabTitle, new JPanel()); + return this; + } + + public PropertiesDialogBuilder onOk(Runnable action) { + this.onOkAction = action; + return this; + } + + public PropertiesDialogBuilder onApply(Runnable action) { + this.onApplyAction = action; + return this; + } + + public JDialog build() { + + JPanel mainPanel = (JPanel) dialog.getContentPane(); + mainPanel.add(createBottomPanel(), BorderLayout.SOUTH); + return dialog; + } + + private JPanel createBottomPanel() { + JPanel bottomPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 10, 10)); + + JButton btnOk = new JButton("OK"); + JButton btnCancel = new JButton("Cancel"); + JButton btnApply = new JButton("Apply"); + JButton btnHelp = new JButton("Help"); + + + Dimension btnSize = new Dimension(85, 26); + btnOk.setPreferredSize(btnSize); + btnCancel.setPreferredSize(btnSize); + btnApply.setPreferredSize(btnSize); + btnHelp.setPreferredSize(btnSize); + + btnCancel.addActionListener(e -> dialog.dispose()); + + btnOk.addActionListener(e -> { + if (onOkAction != null) onOkAction.run(); + dialog.dispose(); + }); + + btnApply.addActionListener(e -> { + if (onApplyAction != null) onApplyAction.run(); + }); + + bottomPanel.add(btnOk); + bottomPanel.add(btnCancel); + bottomPanel.add(btnApply); + bottomPanel.add(btnHelp); + + return bottomPanel; + } + + public static class TabBuilder { + private final PropertiesDialogBuilder parentBuilder; + private final JPanel gridPanel; + private final GridBagConstraints gbc; + private int currentRow = 0; + private final String title; + + public TabBuilder(PropertiesDialogBuilder parentBuilder, String title) { + this.parentBuilder = parentBuilder; + this.title = title; + + gridPanel = new JPanel(new GridBagLayout()); + gridPanel.setBorder(new EmptyBorder(15, 15, 15, 15)); + + gbc = new GridBagConstraints(); + gbc.insets = new Insets(8, 10, 8, 10); + } + + public TabBuilder addPropertyRow(String labelText, JComponent field) { + gbc.gridy = currentRow; + + gbc.gridx = 0; gbc.weightx = 0.0; + gbc.anchor = GridBagConstraints.EAST; gbc.fill = GridBagConstraints.NONE; gbc.gridwidth = 1; + gridPanel.add(new JLabel(labelText), gbc); + + gbc.gridx = 1; gbc.weightx = 1.0; + gbc.anchor = GridBagConstraints.WEST; gbc.fill = GridBagConstraints.HORIZONTAL; gbc.gridwidth = 2; + gridPanel.add(field, gbc); + + currentRow++; + return this; + } + + public TabBuilder addIconRow(String labelText, JComponent field, JButton iconButton) { + gbc.gridy = currentRow; + + gbc.gridx = 0; gbc.weightx = 0.0; gbc.anchor = GridBagConstraints.EAST; gbc.fill = GridBagConstraints.NONE; + gridPanel.add(new JLabel(labelText), gbc); + + gbc.gridx = 1; gbc.weightx = 1.0; gbc.anchor = GridBagConstraints.WEST; gbc.fill = GridBagConstraints.HORIZONTAL; + gridPanel.add(field, gbc); + + gbc.gridx = 2; gbc.gridheight = 2; gbc.weightx = 0.0; gbc.fill = GridBagConstraints.NONE; gbc.anchor = GridBagConstraints.CENTER; + iconButton.setPreferredSize(new Dimension(65, 65)); + iconButton.setBackground(Color.WHITE); + gridPanel.add(iconButton, gbc); + + gbc.gridheight = 1; + currentRow++; + return this; + } + + public TabBuilder addCustomComponent(JComponent component) { + gbc.gridy = currentRow; + gbc.gridx = 0; + gbc.gridwidth = 3; + gbc.weightx = 1.0; + gbc.weighty = 1.0; + gbc.fill = GridBagConstraints.BOTH; + gridPanel.add(component, gbc); + + gbc.gridwidth = 1; + gbc.weighty = 0.0; + currentRow++; + return this; + } + + + public PropertiesDialogBuilder endTab() { + JPanel outerPanel = new JPanel(new BorderLayout()); + outerPanel.add(gridPanel, BorderLayout.NORTH); + parentBuilder.tabbedPane.addTab(title, outerPanel); + return parentBuilder; + } + } +} \ No newline at end of file diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/PropertiesFactory.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/PropertiesFactory.java new file mode 100644 index 000000000..cbf3853e8 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/PropertiesFactory.java @@ -0,0 +1,146 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.plc4x.malbec.s88.plant.panels; +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.plant.impl.Plc4xPlantModel; + +import javax.swing.*; +import java.awt.event.ItemEvent; +import java.io.IOException; + +public class PropertiesFactory { + + public static JDialog createDialog(S88Element element, Plc4xPlantModel model) { + String levelName = element.getLevel().name(); + String className = element.getElementClass() != null ? element.getElementClass().getName() : ""; + String id = element.getId(); + + return switch (element.getLevel()) { + case PROCESSCELL -> buildProcessCellDialog(id, levelName, className, element, model); + case UNIT -> buildUnitDialog(id, levelName, className, element, model); + case EQUIPMENTMODULE -> buildEquipmentModuleDialog(id, levelName, className, element, model); + default -> throw new IllegalArgumentException("No dialog implemented for level: " + levelName); + }; + } + + private static JCheckBox AddCheck(S88Element element){ + JCheckBox check = new JCheckBox(); + check.setSelected(element.isCheck()); + + check.addItemListener(e -> { + if (e.getStateChange() == ItemEvent.SELECTED) { + element.setCheck(true); + } else { + element.setCheck(false); + } + }); + + return check; + } + + private static JDialog buildProcessCellDialog(String id, String level, String elementClass, S88Element element, Plc4xPlantModel model) { + return createBaseBuilder("Edit Process Cell " + id, id, level, elementClass, element) + .onOk(()->{ + try { + model.save(); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .onApply(()->{ + try { + model.save(); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .build(); + } + + private static JDialog buildEquipmentModuleDialog(String id, String level, String elementClass, S88Element element, Plc4xPlantModel model) { + return createBaseBuilder("Edit Equipment Module " + id, id, level, elementClass, element) + .beginTab("Parameters and Reports") + .endTab() + .addEmptyTab("Arbitration") + .addEmptyTab("Cross Invocation") + .addEmptyTab("External sources") + .onOk(()->{ + try { + model.save(); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .onApply(()->{ + try { + model.save(); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .build(); + } + + private static JDialog buildUnitDialog(String id, String level, String elementClass, S88Element element, Plc4xPlantModel model) { + return createBaseBuilder("Edit Unit: " + id, id, level, elementClass, element) + .beginTab("Attribute Tags") + .endTab() + .addEmptyTab("Arbitration") + .addEmptyTab("Cross Invocation") + .addEmptyTab("External sources") + .onOk(() -> { + try { + model.save(); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .onApply(()->{ + try { + model.save(); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .build(); + } + + private static PropertiesDialogBuilder createBaseBuilder(String dialogTitle, String id, String level, String elementClass, S88Element element) { + JTextField nameField = new JTextField(id); + nameField.setEditable(false); + + JTextField levelField = new JTextField(level); + levelField.setEditable(false); + + JTextField classField = new JTextField(elementClass); + classField.setEditable(false); + + PropertiesDialogBuilder builder = new PropertiesDialogBuilder(dialogTitle); + + builder.beginTab("General") + .addPropertyRow("Name", nameField) + .addPropertyRow("Level", levelField) + .addPropertyRow("Template/Class", classField) + .addPropertyRow("Enable display creation", AddCheck(element)) + .endTab(); + + return builder; + } +} \ No newline at end of file diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/TemplateDialogBuilder.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/TemplateDialogBuilder.java new file mode 100644 index 000000000..64f5c4c53 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/TemplateDialogBuilder.java @@ -0,0 +1,149 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.plc4x.malbec.s88.plant.panels; + +import org.openide.DialogDisplayer; +import org.openide.NotifyDescriptor; +import org.openide.util.Exceptions; +import javax.swing.*; +import javax.swing.border.EmptyBorder; +import java.awt.*; + +public class TemplateDialogBuilder { + private final JDialog dialog; + private final JPanel formPanel; + private JTextField txtTemplateName; + private int currentRow = 0; + private final Boolean showButtons; + + private Runnable onOkAction; + + public TemplateDialogBuilder(String title) { + this(title, true, null); + } + + public TemplateDialogBuilder(String title, Window owner) { + this(title, true, owner); + } + + public TemplateDialogBuilder(String title, boolean showButtons) { + this(title, showButtons, null); + } + + public TemplateDialogBuilder(String title, boolean showButtons, Window owner) { + dialog = new JDialog(owner, title, Dialog.ModalityType.APPLICATION_MODAL); + formPanel = new JPanel(new GridBagLayout()); + this.showButtons = showButtons; + } + + public TemplateDialogBuilder withNameField() { + txtTemplateName = new JTextField(20); + addRow("Template Name:", txtTemplateName); + return this; + } + + public TemplateDialogBuilder withReadOnlyNameField(String value) { + JTextField field = new JTextField(value != null ? value : ""); + field.setEditable(false); + addRow("Template Name:", field); + return this; + } + + public void addRow(String labelText, JComponent component) { + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(5, 5, 5, 5); + gbc.gridx = 0; gbc.gridy = currentRow; + gbc.weightx = 0.0; gbc.anchor = GridBagConstraints.EAST; + formPanel.add(new JLabel(labelText), gbc); + + gbc.gridx = 1; gbc.weightx = 1.0; + gbc.fill = GridBagConstraints.HORIZONTAL; gbc.anchor = GridBagConstraints.WEST; + formPanel.add(component, gbc); + currentRow++; + } + + public TemplateDialogBuilder addComponentRow(JComponent component) { + GridBagConstraints gbc = new GridBagConstraints(); + gbc.insets = new Insets(5, 5, 5, 5); + gbc.gridwidth = 2; gbc.gridx = 0; gbc.gridy = currentRow; + gbc.weightx = 1.0; gbc.weighty = 1.0; + gbc.fill = GridBagConstraints.BOTH; + formPanel.add(component, gbc); + currentRow++; + return this; + } + + public TemplateDialogBuilder onAccept(Runnable action) { + this.onOkAction = action; + return this; + } + + public String getTemplateName() { + return txtTemplateName != null ? txtTemplateName.getText() : ""; + } + + public JDialog getDialog() { + return dialog; + } + + public JDialog build() { + JPanel contentPane = new JPanel(new BorderLayout(10, 10)); + contentPane.setBorder(new EmptyBorder(15, 15, 15, 15)); + + JScrollPane scrollPane = new JScrollPane(formPanel); + scrollPane.setBorder(null); + contentPane.add(scrollPane, BorderLayout.CENTER); + if(Boolean.TRUE.equals(showButtons)) { + JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 10, 0)); + JButton btnOk = new JButton("OK"); + JButton btnCancel = new JButton("Cancel"); + btnOk.setPreferredSize(new Dimension(80, 26)); + btnCancel.setPreferredSize(new Dimension(80, 26)); + + btnCancel.addActionListener(e -> dialog.dispose()); + + btnOk.addActionListener(e -> { + try { + if (onOkAction != null) { + onOkAction.run(); + } + dialog.dispose(); + } catch (IllegalArgumentException | IllegalStateException ex) { + DialogDisplayer.getDefault().notify(new NotifyDescriptor.Message(ex.getMessage(), NotifyDescriptor.ERROR_MESSAGE)); + } catch (Exception ex) { + Exceptions.printStackTrace(ex); + } + }); + + buttonPanel.add(btnOk); + buttonPanel.add(btnCancel); + contentPane.add(buttonPanel, BorderLayout.SOUTH); + } + dialog.setContentPane(contentPane); + + if(Boolean.TRUE.equals(showButtons)) { + dialog.getRootPane().setDefaultButton((JButton) null); + } + dialog.pack(); + dialog.setLocationRelativeTo(null); + + return dialog; + } +} \ No newline at end of file diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/TemplateFactory.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/TemplateFactory.java new file mode 100644 index 000000000..4568b9f26 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/panels/TemplateFactory.java @@ -0,0 +1,383 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.plc4x.malbec.s88.plant.panels; + +import org.apache.plc4x.malbec.s88.api.DataType; +import org.apache.plc4x.malbec.s88.api.S88Element; +import org.apache.plc4x.malbec.s88.api.S88ElementClass; +import org.apache.plc4x.malbec.s88.api.S88Enumeration; +import org.apache.plc4x.malbec.s88.api.S88Level; +import org.apache.plc4x.malbec.s88.plant.impl.Plc4xPlantModel; +import org.apache.plc4x.malbec.s88.core.CreateClassUseCase; +import org.openide.DialogDisplayer; +import org.openide.NotifyDescriptor; + +import javax.swing.*; +import javax.swing.border.TitledBorder; +import javax.swing.table.DefaultTableModel; +import java.awt.*; +import java.util.List; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; + +public class TemplateFactory { + + public static void createDialog(S88Element parent, Plc4xPlantModel model) { + createDialog(parent, model, null); + } + + public static void createDialog(S88Element parent, Plc4xPlantModel model, Window owner) { + S88Level childLevel = parent.getLevel() != null ? parent.getLevel().getChildLevel() : null; + if (childLevel == null) { + DialogDisplayer.getDefault().notify(new NotifyDescriptor.Message( + "Cannot create a template under an Equipment Module (leaf level).", + NotifyDescriptor.INFORMATION_MESSAGE)); + return; + } + + JDialog dialog = switch (parent.getLevel()) { + case AREA -> createSimpleTemplateDialog(parent, model, owner); + case PROCESSCELL -> createUnitTemplateDialog(parent, model, owner); + case UNIT -> createEMTemplateDialog(parent, model, owner); + case EQUIPMENTMODULE -> createSimpleTemplateDialog(parent, model, owner); + default -> throw new IllegalArgumentException("No dialog implemented for level: " + parent.getLevel().name()); + }; + + if(dialog != null){ + dialog.pack(); + dialog.setLocationRelativeTo(null); + dialog.setVisible(true); + } + + } + + public static JDialog createSimpleTemplateDialog(S88Element parent, Plc4xPlantModel model, Window owner) { + TemplateDialogBuilder builder = new TemplateDialogBuilder("Create " + parent.getLevel().getChildLevel() + " Template", owner); + + Runnable okLogic = () -> { + CreateClassUseCase.execute(model.getModel(), parent, builder.getTemplateName(), null); + try { + model.save(); + } catch (IOException e) { + throw new RuntimeException(e); + } + }; + + return builder + .withNameField() + .onAccept(okLogic) + .build(); + } + + public static JDialog createUnitTemplateDialog(S88Element parent, Plc4xPlantModel model, Window owner) { + String[] columns = {"Name", "Eng_Units/Enum", "Type"}; + DefaultTableModel tableModel = createReadOnlyTableModel(columns); + + List enumerations = (model != null && model.getModel() != null) + ? model.getModel().getEnumerations() + : List.of(); + + TemplateDialogBuilder builder = new TemplateDialogBuilder("Create " + parent.getLevel().getChildLevel() + " Template", owner); + + JPanel attributePanel = createAttributeTabPanel(tableModel, enumerations, builder::getDialog); + + Runnable okLogic = () -> { + Map propertyMap = buildPropertiesFromTable(tableModel); + assert model != null; + CreateClassUseCase.execute(model.getModel(), parent, builder.getTemplateName(), propertyMap); + try { + model.save(); + } catch (IOException e) { + throw new RuntimeException(e); + } + }; + + return builder + .withNameField() + .addComponentRow(attributePanel) + .onAccept(okLogic) + .build(); + } + + public static JDialog createEMTemplateDialog(S88Element parent, Plc4xPlantModel model, Window owner) { + DefaultTableModel paramsTableModel = createReadOnlyTableModel(new String[]{"Name", "Eng_Units/Enum", "Type", "Max", "Min", "Default"}); + DefaultTableModel reportsTableModel = createReadOnlyTableModel(new String[]{"Name", "Eng_Units/Enum", "Type"}); + + List enumerations = (model != null && model.getModel() != null) + ? model.getModel().getEnumerations() + : List.of(); + + TemplateDialogBuilder builder = new TemplateDialogBuilder("Create " + parent.getLevel().getChildLevel() + " Template", owner); + + Supplier ownerSupplier = builder::getDialog; + + JTabbedPane tabbedPane = new JTabbedPane(); + tabbedPane.addTab("Parameters", createEntryTabPanel(paramsTableModel, enumerations, false, "Add", "Parameter", ownerSupplier)); + tabbedPane.addTab("Reports", createEntryTabPanel(reportsTableModel, enumerations, true, "Add report", "Report", ownerSupplier)); + + Runnable okLogic = () -> { + Map propertyBag = new LinkedHashMap<>(); + propertyBag.put("Parameters", buildPropertiesFromTable(paramsTableModel)); + propertyBag.put("Reports", buildPropertiesFromTable(reportsTableModel)); + + assert model != null; + CreateClassUseCase.execute(model.getModel(), parent, builder.getTemplateName(), propertyBag); + try { + model.save(); + } catch (IOException e) { + throw new RuntimeException(e); + } + }; + + return builder + .withNameField() + .addComponentRow(tabbedPane) + .onAccept(okLogic) + .build(); + } + + + private static ParameterDialogBuilder parameterDialog(String title, List enumerations, boolean reports, Window owner) { + ParameterDialogBuilder builder = new ParameterDialogBuilder(title).withEnumerations(enumerations).withOwner(owner).withReference(false); + return reports ? builder.reportsMode() : builder; + } + + private static JPanel createEntryTabPanel(DefaultTableModel tableModel, List enumerations, + boolean reports, String addButtonLabel, String titleNoun, + Supplier ownerSupplier) { + JPanel panel = new JPanel(new BorderLayout()); + JTable table = createStandardTable(tableModel); + + JPanel paramsButtons = new JPanel(new GridLayout(1, 2, 5, 0)); + JButton addP = new JButton(addButtonLabel); + JButton removeP = new JButton("Remove"); + + addP.addActionListener(e -> parameterDialog("Add " + titleNoun, enumerations, reports, ownerSupplier.get()) + .onSave((name, params) -> { + Object[] row = new Object[tableModel.getColumnCount()]; + row[0] = name; + for (int j = 1; j < row.length; j++) { + row[j] = Objects.toString(params.get(tableModel.getColumnName(j)), ""); + } + tableModel.addRow(row); + }) + .show()); + + removeP.addActionListener(e -> { + int selectedRow = table.getSelectedRow(); + if (selectedRow != -1) { + tableModel.removeRow(selectedRow); + } + }); + + table.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent me) { + if (me.getClickCount() == 2) { + int row = table.rowAtPoint(me.getPoint()); + if (row == -1) { + return; + } + Map rowData = readRowAsMap(tableModel, row); + String name = Objects.toString(rowData.remove("Name"), ""); + parameterDialog("Edit " + titleNoun, enumerations, reports, ownerSupplier.get()) + .withInitialData(name, rowData) + .onSave((updatedName, params) -> { + tableModel.setValueAt(updatedName, row, 0); + for (int j = 1; j < tableModel.getColumnCount(); j++) { + tableModel.setValueAt( + Objects.toString(params.get(tableModel.getColumnName(j)), ""), + row, j); + } + }) + .show(); + } + } + }); + + paramsButtons.add(addP); + paramsButtons.add(removeP); + panel.add(paramsButtons, BorderLayout.NORTH); + panel.add(new JScrollPane(table), BorderLayout.CENTER); + return panel; + } + + private static JPanel createAttributeTabPanel(DefaultTableModel tableModel, List enumerations, + Supplier ownerSupplier) { + JPanel panel = createEntryTabPanel(tableModel, enumerations, true, "Add attribute", "Attribute", ownerSupplier); + panel.setBorder(BorderFactory.createTitledBorder( + BorderFactory.createLineBorder(Color.LIGHT_GRAY), + "Unit attributes", TitledBorder.LEFT, TitledBorder.TOP)); + return panel; + } + + private static Map readRowAsMap(DefaultTableModel tableModel, int row) { + Map data = new LinkedHashMap<>(); + for (int j = 0; j < tableModel.getColumnCount(); j++) { + Object value = tableModel.getValueAt(row, j); + data.put(tableModel.getColumnName(j), value != null ? value.toString() : ""); + } + return data; + } + + private static JTable createStandardTable(DefaultTableModel tableModel){ + JTable table = new JTable(tableModel); + JComboBox typeCombo = new JComboBox<>(DataType.displayNames()); + table.getColumnModel().getColumn(2).setCellEditor(new DefaultCellEditor(typeCombo)); + table.getTableHeader().setReorderingAllowed(false); + table.setShowGrid(true); + table.setGridColor(Color.LIGHT_GRAY); + table.setFillsViewportHeight(true); + + return table; + } + + + private static Map buildPropertiesFromTable(DefaultTableModel tableModel) { + Map propertyMap = new LinkedHashMap<>(); + int rowCount = tableModel.getRowCount(); + int colCount = tableModel.getColumnCount(); + + for (int i = 0; i < rowCount; i++) { + Object nameObj = tableModel.getValueAt(i, 0); + String name = (nameObj != null) ? nameObj.toString().trim() : ""; + + if (name.isEmpty()) { + continue; + } + + Map property = new LinkedHashMap<>(); + + for (int j = 1; j < colCount; j++) { + String columnName = tableModel.getColumnName(j); + Object cellValueObj = tableModel.getValueAt(i, j); + String cellValue = (cellValueObj != null) ? cellValueObj.toString() : ""; + + if (columnName.equals("Type") && cellValue.isEmpty()) { + cellValue = DataType.defaultForEmpty().name(); + } + + property.put(columnName, cellValue); + } + + propertyMap.put(name, property); + } + return propertyMap; + } + + public static void showTemplate(S88ElementClass ec) { + JDialog dialog = switch (ec.getTargetLevel()) { + case UNIT -> showUnitTemplate(ec); + case EQUIPMENTMODULE -> showEMTemplate(ec); + default -> null; + }; + + if (dialog != null) { + dialog.pack(); + dialog.setLocationRelativeTo(null); + dialog.setVisible(true); + } + } + + public static JDialog showUnitTemplate(S88ElementClass ec) { + String[] columns = {"Name", "Eng_Units/Enum", "Type"}; + DefaultTableModel tableModel = createReadOnlyTableModel(columns); + + populateTable(tableModel, ec.getProperties()); + + JPanel attributePanel = createReadOnlyAttributePanel(tableModel); + + TemplateDialogBuilder builder = new TemplateDialogBuilder(ec.getName() != null ? ec.getName() : "Template") + .withReadOnlyNameField(ec.getName()) + .addComponentRow(attributePanel) + .onAccept(() -> { }); + return builder.build(); + } + + public static JDialog showEMTemplate(S88ElementClass ec) { + DefaultTableModel paramsTableModel = createReadOnlyTableModel(new String[]{"Name", "Eng_Units/Enum", "Type", "Max", "Min", "Default"}); + DefaultTableModel reportsTableModel = createReadOnlyTableModel(new String[]{"Name", "Eng_Units/Enum", "Type"}); + + Object params = ec.getProperty("Parameters"); + if (params instanceof Map) { + populateTable(paramsTableModel, (Map) params); + } + Object reports = ec.getProperty("Reports"); + if (reports instanceof Map) { + populateTable(reportsTableModel, (Map) reports); + } + + JTabbedPane tabbedPane = new JTabbedPane(); + tabbedPane.addTab("Parameters", createReadOnlyTabPanel(paramsTableModel)); + tabbedPane.addTab("Reports", createReadOnlyTabPanel(reportsTableModel)); + + TemplateDialogBuilder builder = new TemplateDialogBuilder(ec.getName() != null ? ec.getName() : "Template") + .withReadOnlyNameField(ec.getName()) + .addComponentRow(tabbedPane) + .onAccept(() -> { }); + return builder.build(); + } + + private static void populateTable(DefaultTableModel tableModel, Map propertiesMap) { + int colCount = tableModel.getColumnCount(); + for (Map.Entry entry : propertiesMap.entrySet()) { + Object[] row = new Object[colCount]; + row[0] = entry.getKey(); + Object val = entry.getValue(); + Map values = val instanceof Map ? (Map) val : null; + for (int i = 1; i < colCount; i++) { + String columnName = tableModel.getColumnName(i); + Object v = values != null ? values.get(columnName) : null; + row[i] = v != null ? v : ""; + } + tableModel.addRow(row); + } + } + + private static JPanel createReadOnlyAttributePanel(DefaultTableModel tableModel) { + JPanel attributePanel = new JPanel(new BorderLayout()); + attributePanel.setBorder(BorderFactory.createTitledBorder( + BorderFactory.createLineBorder(Color.LIGHT_GRAY), + "Unit attributes", TitledBorder.LEFT, TitledBorder.TOP)); + attributePanel.add(new JScrollPane(createStandardTable(tableModel)), BorderLayout.CENTER); + return attributePanel; + } + + private static JPanel createReadOnlyTabPanel(DefaultTableModel tableModel) { + JPanel panel = new JPanel(new BorderLayout()); + panel.add(new JScrollPane(createStandardTable(tableModel)), BorderLayout.CENTER); + return panel; + } + + private static DefaultTableModel createReadOnlyTableModel(String[] columns) { + return new DefaultTableModel(null, columns) { + @Override + public boolean isCellEditable(int row, int column) { + return false; + } + }; + } + +} \ No newline at end of file diff --git a/plc4j/tools/ui/application/src/main/java/org/apache/plc4x/java/tools/ui/Plc4xUiBackendApplication.java b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/services/S88ProjectServices.java similarity index 52% rename from plc4j/tools/ui/application/src/main/java/org/apache/plc4x/java/tools/ui/Plc4xUiBackendApplication.java rename to plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/services/S88ProjectServices.java index 6a769ed2c..09b60f6fb 100644 --- a/plc4j/tools/ui/application/src/main/java/org/apache/plc4x/java/tools/ui/Plc4xUiBackendApplication.java +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/java/org/apache/plc4x/malbec/s88/plant/services/S88ProjectServices.java @@ -17,18 +17,24 @@ * under the License. */ -package org.apache.plc4x.java.tools.ui; +package org.apache.plc4x.malbec.s88.plant.services; -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.apache.plc4x.malbec.s88.api.S88Repository; +import org.apache.plc4x.malbec.s88.api.S88RepositoryProvider; +import org.apache.plc4x.malbec.s88.api.S88Storage; +import org.openide.util.Lookup; -@SpringBootApplication -@EnableJpaRepositories("org.apache.plc4x.java.tools.ui") -public class Plc4xUiBackendApplication { +/** + * NetBeans integration service for S88 repositories. + */ +public final class S88ProjectServices { - public static void main(String[] args) { - SpringApplication.run(Plc4xUiBackendApplication.class, args); - } + public static S88Repository createRepository(String format, S88Storage storage) { + var providers = Lookup.getDefault().lookupAll(S88RepositoryProvider.class); -} + for (S88RepositoryProvider p : providers) { + if (p.accepts(format)) return p.createRepository(storage); + } + throw new IllegalArgumentException("Unsupported format: " + format); + } +} \ No newline at end of file diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/nbm/manifest.mf b/plc4j/tools/malbec/malbec-s88/plant/src/main/nbm/manifest.mf new file mode 100644 index 000000000..a7604ba67 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/nbm/manifest.mf @@ -0,0 +1,4 @@ +Manifest-Version: 1.0 +OpenIDE-Module-Localizing-Bundle: org/apache/plc4x/malbec/s88/plant/Bundle.properties +OpenIDE-Module-Requires: org.openide.windows.WindowManager + diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/resources/org/apache/plc4x/malbec/s88/plant/Bundle.properties b/plc4j/tools/malbec/malbec-s88/plant/src/main/resources/org/apache/plc4x/malbec/s88/plant/Bundle.properties new file mode 100644 index 000000000..d1b72fe48 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/resources/org/apache/plc4x/malbec/s88/plant/Bundle.properties @@ -0,0 +1,23 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +#Localized module labels. Defaults taken from POM (, , ) if unset. +#OpenIDE-Module-Name= +#OpenIDE-Module-Short-Description= +#OpenIDE-Module-Long-Description= +#OpenIDE-Module-Display-Category= +#Wed May 06 14:58:47 VET 2026 diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/resources/org/apache/plc4x/malbec/s88/plant/nodes/EquipmentModule.png b/plc4j/tools/malbec/malbec-s88/plant/src/main/resources/org/apache/plc4x/malbec/s88/plant/nodes/EquipmentModule.png new file mode 100644 index 000000000..7418f5943 Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/plant/src/main/resources/org/apache/plc4x/malbec/s88/plant/nodes/EquipmentModule.png differ diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/resources/org/apache/plc4x/malbec/s88/plant/nodes/PlantNode.png b/plc4j/tools/malbec/malbec-s88/plant/src/main/resources/org/apache/plc4x/malbec/s88/plant/nodes/PlantNode.png new file mode 100644 index 000000000..91800c1bd Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/plant/src/main/resources/org/apache/plc4x/malbec/s88/plant/nodes/PlantNode.png differ diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/resources/org/apache/plc4x/malbec/s88/plant/nodes/ProcessCell.png b/plc4j/tools/malbec/malbec-s88/plant/src/main/resources/org/apache/plc4x/malbec/s88/plant/nodes/ProcessCell.png new file mode 100644 index 000000000..6e3200a70 Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/plant/src/main/resources/org/apache/plc4x/malbec/s88/plant/nodes/ProcessCell.png differ diff --git a/plc4j/tools/malbec/malbec-s88/plant/src/main/resources/org/apache/plc4x/malbec/s88/plant/nodes/Unit.png b/plc4j/tools/malbec/malbec-s88/plant/src/main/resources/org/apache/plc4x/malbec/s88/plant/nodes/Unit.png new file mode 100644 index 000000000..0e47e5191 Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/plant/src/main/resources/org/apache/plc4x/malbec/s88/plant/nodes/Unit.png differ diff --git a/plc4j/tools/ui/application/src/test/resources/application-test.properties b/plc4j/tools/malbec/malbec-s88/plant/src/main/resources/org/apache/plc4x/malbec/s88/plant/panels/Bundle.properties similarity index 78% rename from plc4j/tools/ui/application/src/test/resources/application-test.properties rename to plc4j/tools/malbec/malbec-s88/plant/src/main/resources/org/apache/plc4x/malbec/s88/plant/panels/Bundle.properties index b4082c8c9..cd600e6a8 100644 --- a/plc4j/tools/ui/application/src/test/resources/application-test.properties +++ b/plc4j/tools/malbec/malbec-s88/plant/src/main/resources/org/apache/plc4x/malbec/s88/plant/panels/Bundle.properties @@ -1,4 +1,3 @@ -# # Licensed to the Apache Software Foundation (ASF) under one # or more contributor license agreements. See the NOTICE file # distributed with this work for additional information @@ -7,7 +6,7 @@ # "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 +# 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 @@ -15,5 +14,9 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -# -spring.datasource.url=jdbc:derby:directory:target/storage/settings;create=true + + +ExportDialog.jButton1.text=jButton1 +ExportDialog.jButton2.text=jButton2 +ExportDialog.jButton1.text_1=jButton1 +ExportDialog.jButton2.text_1=jButton2 diff --git a/plc4j/tools/malbec/malbec-s88/pom.xml b/plc4j/tools/malbec/malbec-s88/pom.xml new file mode 100644 index 000000000..2e2bbc743 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/pom.xml @@ -0,0 +1,40 @@ + + + + 4.0.0 + + org.apache.plc4x + malbec-parent + 1.1.0-SNAPSHOT + + + org.apache.plc4x.malbec.s88 + s88 + ${project.groupId}:${project.artifactId} + pom + + api + data + core + plant + project + batch + + \ No newline at end of file diff --git a/plc4j/tools/malbec/malbec-s88/project/pom.xml b/plc4j/tools/malbec/malbec-s88/project/pom.xml new file mode 100644 index 000000000..4869179f4 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/project/pom.xml @@ -0,0 +1,154 @@ + + + + 4.0.0 + + org.apache.plc4x.malbec.s88 + s88 + 1.1.0-SNAPSHOT + + + project + org.apache.plc4x.malbec.s88:project + nbm + + + + org.apache.netbeans.utilities + nbm-maven-plugin + true + + + + org.apache.maven.plugins + maven-jar-plugin + + + ${project.build.outputDirectory}/META-INF/MANIFEST.MF + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.1 + + false + full + + + + + + + org.netbeans.api + org-netbeans-api-annotations-common + ${netbeans.version} + + + org.netbeans.api + org-openide-windows + ${netbeans.version} + + + org.netbeans.api + org-openide-nodes + ${netbeans.version} + + + org.netbeans.api + org-openide-util + ${netbeans.version} + + + org.netbeans.api + org-openide-util-ui + ${netbeans.version} + + + org.netbeans.api + org-openide-util-lookup + ${netbeans.version} + + + org.netbeans.api + org-openide-awt + ${netbeans.version} + + + org.netbeans.api + org-netbeans-modules-settings + ${netbeans.version} + + + org.netbeans.api + org-netbeans-modules-projectapi + ${netbeans.version} + jar + + + org.netbeans.api + org-netbeans-modules-projectuiapi + ${netbeans.version} + jar + + + org.netbeans.api + org-openide-dialogs + ${netbeans.version} + + + org.netbeans.api + org-openide-loaders + ${netbeans.version} + + + org.netbeans.api + org-openide-filesystems + ${netbeans.version} + + + org.netbeans.api + org-netbeans-api-templates + ${netbeans.version} + + + org.netbeans.api + org-openide-actions + ${netbeans.version} + + + + org.netbeans.api + org-openide-text + ${netbeans.version} + + + org.netbeans.api + org-netbeans-core-multiview + ${netbeans.version} + + + org.apache.plc4x.malbec.s88 + plant + ${project.version} + + + \ No newline at end of file diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/impl/FolderInstaller.java b/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/impl/FolderInstaller.java new file mode 100644 index 000000000..1b0f27342 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/impl/FolderInstaller.java @@ -0,0 +1,39 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.plc4x.malbec.s88.impl; + +import org.netbeans.spi.project.ui.support.ProjectChooser; +import org.openide.modules.OnStart; + +import java.io.File; + +@OnStart +public class FolderInstaller implements Runnable { + @Override + public void run() { + File dir = new File(System.getProperty("user.home"), "MalbecProjects"); + + if(!dir.exists()) { + dir.mkdirs(); + } + + ProjectChooser.setProjectsFolder(dir); + } +} diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/impl/Plc4xCustomizerProviderImpl.java b/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/impl/Plc4xCustomizerProviderImpl.java new file mode 100644 index 000000000..13d3b639f --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/impl/Plc4xCustomizerProviderImpl.java @@ -0,0 +1,67 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.malbec.s88.impl; + +import java.awt.Dialog; +import java.awt.event.ActionEvent; +import java.awt.event.ActionListener; +import org.netbeans.api.project.ProjectUtils; +import org.netbeans.spi.project.ui.CustomizerProvider2; +import org.netbeans.spi.project.ui.support.ProjectCustomizer; +import org.openide.awt.StatusDisplayer; +import org.openide.util.lookup.Lookups; + +public record Plc4xCustomizerProviderImpl(Plc4xProjectImpl project) implements CustomizerProvider2 { + + public static final String CUSTOMIZER_FOLDER_PATH = + "Projects/org-plc4x-s88-project/Customizer"; + + @Override + public void showCustomizer(String string, String string1) { + // + } + + @Override + public void showCustomizer() { + Dialog dialog = ProjectCustomizer.createCustomizerDialog( + //Path to layer folder: + CUSTOMIZER_FOLDER_PATH, + //Lookup, which must contain, at least, the Project: + Lookups.fixed(project), + //Preselected category: + "", + //OK button listener: + new OKOptionListener(), + //HelpCtx for Help button of dialog: + null); + dialog.setTitle(ProjectUtils.getInformation(project).getDisplayName()); + dialog.setVisible(true); + } + + private class OKOptionListener implements ActionListener { + + @Override + public void actionPerformed(ActionEvent e) { + StatusDisplayer.getDefault().setStatusText("OK button clicked for " + + project.getProjectDirectory().getName() + " customizer!"); + } + + } + +} diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/impl/Plc4xProjectFactoryImpl.java b/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/impl/Plc4xProjectFactoryImpl.java new file mode 100644 index 000000000..2617e94bb --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/impl/Plc4xProjectFactoryImpl.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.malbec.s88.impl; + +import java.io.IOException; +import org.netbeans.api.project.Project; +import org.netbeans.spi.project.ProjectFactory; +import org.netbeans.spi.project.ProjectState; +import org.openide.filesystems.FileObject; +import org.openide.util.lookup.ServiceProvider; + +@ServiceProvider(service=ProjectFactory.class) +public class Plc4xProjectFactoryImpl implements ProjectFactory { + + public static final String PROJECT_FILE = "plant.cfg"; + + @Override + public boolean isProject(FileObject fo) { + return fo.getFileObject(PROJECT_FILE) != null; + } + + @Override + public Project loadProject(FileObject fo, ProjectState ps) throws IOException { + return isProject(fo) ? new Plc4xProjectImpl(fo, ps) : null; + } + + @Override + public void saveProject(Project prjct) throws IOException, ClassCastException { + // + } + +} diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/impl/Plc4xProjectImpl.java b/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/impl/Plc4xProjectImpl.java new file mode 100644 index 000000000..cf68ae1b4 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/impl/Plc4xProjectImpl.java @@ -0,0 +1,108 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.malbec.s88.impl; + +import java.beans.PropertyChangeListener; +import javax.swing.Icon; +import javax.swing.ImageIcon; +import org.apache.plc4x.malbec.s88.panels.Plc4xGeneralPropertiesImpl; +import org.apache.plc4x.malbec.s88.plant.impl.Plc4xPlantSubProjectProviderImpl; +import org.netbeans.api.annotations.common.StaticResource; +import org.netbeans.api.project.Project; +import org.netbeans.api.project.ProjectInformation; +import org.netbeans.spi.project.ProjectState; +import org.openide.filesystems.FileObject; +import org.openide.util.ImageUtilities; +import org.openide.util.Lookup; +import org.openide.util.lookup.Lookups; + +public class Plc4xProjectImpl implements Project{ + + private final FileObject fo; + private final ProjectState ps; + private Lookup lkp; + + + public Plc4xProjectImpl(FileObject fo, ProjectState ps) { + this.fo = fo; + this.ps = ps; + } + + @Override + public FileObject getProjectDirectory() { + return fo; + } + + @Override + public Lookup getLookup() { + if (lkp == null) { + lkp = Lookups.fixed(new Object[]{ + this, + new Plc4xProjectInfoImpl(), + new Plc4xProjectLogicalViewImpl(this), + new Plc4xCustomizerProviderImpl(this), + new Plc4xGeneralPropertiesImpl(), + new Plc4xPlantSubProjectProviderImpl(this), + + }); + } + return lkp; + } + + private class Plc4xProjectInfoImpl implements ProjectInformation { + + + @StaticResource() + public static final String PROJECT_ICON = "org/apache/plc4x/malbec/s88/impl/Project.png"; + + @Override + public String getName() { + return getProjectDirectory().getName(); + } + + @Override + public String getDisplayName() { + return getName(); + } + + @Override + public Icon getIcon() { + return new ImageIcon(ImageUtilities.loadImage(PROJECT_ICON)); + } + + @Override + public Project getProject() { + return Plc4xProjectImpl.this; + } + + @Override + public void addPropertyChangeListener(PropertyChangeListener pl) { + // + } + + @Override + public void removePropertyChangeListener(PropertyChangeListener pl) { + // + } + + } + + + +} diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/impl/Plc4xProjectLogicalViewImpl.java b/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/impl/Plc4xProjectLogicalViewImpl.java new file mode 100644 index 000000000..bae01fcb3 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/impl/Plc4xProjectLogicalViewImpl.java @@ -0,0 +1,123 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.malbec.s88.impl; + +import java.awt.Image; +import java.util.ArrayList; +import java.util.List; +import javax.swing.Action; + +import org.apache.plc4x.malbec.s88.plant.actions.ExportAction; +import org.apache.plc4x.malbec.s88.plant.actions.ImportAction; +import org.netbeans.api.annotations.common.StaticResource; +import org.netbeans.spi.project.ui.LogicalViewProvider; +import org.netbeans.spi.project.ui.support.CommonProjectActions; +import org.netbeans.spi.project.ui.support.NodeFactorySupport; +import org.openide.filesystems.FileObject; +import org.openide.loaders.DataFolder; +import org.openide.loaders.DataObjectNotFoundException; +import org.openide.nodes.AbstractNode; +import org.openide.nodes.Children; +import org.openide.nodes.FilterNode; +import org.openide.nodes.Node; +import org.openide.util.Exceptions; +import org.openide.util.ImageUtilities; +import org.openide.util.Lookup; +import org.openide.util.Utilities; +import org.openide.util.lookup.Lookups; +import org.openide.util.lookup.ProxyLookup; + +public class Plc4xProjectLogicalViewImpl implements LogicalViewProvider { + + + @StaticResource() + public static final String PROJECT_ICON = "org/apache/plc4x/malbec/s88/impl/Project.png"; + + private final Plc4xProjectImpl project; + + public Plc4xProjectLogicalViewImpl(Plc4xProjectImpl project) { + this.project = project; + } + + @Override + public Node createLogicalView() { + try { + FileObject projectDirectory = project.getProjectDirectory(); + DataFolder projectFolder = DataFolder.findFolder(projectDirectory); + Node nodeOfProjectFolder = projectFolder.getNodeDelegate(); + return new ProjectNode(nodeOfProjectFolder, project); + } catch (DataObjectNotFoundException donfe) { + Exceptions.printStackTrace(donfe); + return new AbstractNode(Children.LEAF); + } + } + + @Override + public Node findPath(Node node, Object o) { + return null; + } + + private final class ProjectNode extends FilterNode { + + final Plc4xProjectImpl project; + + public ProjectNode(Node node, Plc4xProjectImpl project) + throws DataObjectNotFoundException { + super(node, + NodeFactorySupport.createCompositeChildren(project, + "Projects/org-plc4x-s88-project/Nodes"), + new ProxyLookup( + new Lookup[]{ + Lookups.singleton(project), + node.getLookup() + })); + this.project = project; + } + + @Override + public Action[] getActions(boolean arg0) { + List actions = new ArrayList<>(); + actions.addAll(Utilities.actionsForPath("Projects/org-plc4x-s88-project/Actions")); + actions.add(null); + actions.add(CommonProjectActions.copyProjectAction()); + actions.add(CommonProjectActions.deleteProjectAction()); + actions.add(CommonProjectActions.closeProjectAction()); + actions.add(null); + actions.add(CommonProjectActions.customizeProjectAction()); + return actions.toArray(new Action[0]); + } + + @Override + public Image getIcon(int type) { + return ImageUtilities.loadImage(PROJECT_ICON); + } + + @Override + public Image getOpenedIcon(int type) { + return getIcon(type); + } + + @Override + public String getDisplayName() { + return project.getProjectDirectory().getName(); + } + + } + +} diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/nodes/Plc4xPlantNode.java b/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/nodes/Plc4xPlantNode.java new file mode 100644 index 000000000..1756ff1fb --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/nodes/Plc4xPlantNode.java @@ -0,0 +1,101 @@ +///* +// * Licensed to the Apache Software Foundation (ASF) under one +// * or more contributor license agreements. See the NOTICE file +// * distributed with this work for additional information +// * regarding copyright ownership. The ASF licenses this file +// * to you 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. +// */ +//package org.apache.plc4x.malbec.s88.nodes; +// +//import java.awt.Image; +//import java.awt.event.ActionEvent; +//import java.util.ArrayList; +//import java.util.List; +//import javax.swing.AbstractAction; +//import javax.swing.Action; +//import org.netbeans.api.annotations.common.StaticResource; +//import org.openide.nodes.AbstractNode; +//import org.openide.nodes.Children; +//import org.openide.nodes.PropertySupport; +//import org.openide.nodes.Sheet; +//import org.openide.util.ImageUtilities; +//import org.openide.util.NbBundle.Messages; +// +///** +// * Node representing the Plant View in the S88 project structure. +// */ +//public class Plc4xPlantNode extends AbstractNode { +// +// @StaticResource +// public static final String PLANT_NODE_ICON = "org/apache/plc4x/malbec/s88/nodes/AvisosBit.png"; +// +// public Plc4xPlantNode(Children children) { +// super(children); +// setName("PlantView"); +// setDisplayName("Plant View"); +// } +// +// @Override +// public Image getIcon(int type) { +// return ImageUtilities.loadImage(PLANT_NODE_ICON, true); +// } +// +// @Override +// public Image getOpenedIcon(int type) { +// return getIcon(type); +// } +// +// @Override +// public Action[] getActions(boolean context) { +// List actions = new ArrayList<>(); +// +// actions.add(new CreatePlantElementAction()); +// actions.add(org.openide.util.actions.SystemAction.get(org.openide.actions.PropertiesAction.class)); +// return actions.toArray(new Action[0]); +// } +// +// @Override +// protected Sheet createSheet(){ +// Sheet sheet = super.createSheet(); +// +// Sheet.Set set = Sheet.createPropertiesSet(); +// set.setName("props"); +// set.setDisplayName("Propiedades"); +// set.setShortDescription("Propiedades del Área."); +// +// set.put(new PropertySupport.ReadOnly("author", String.class, "Autor", "Autor del elemento.") { +// @Override +// public String getValue() { +// return "user"; +// } +// }); +// +// sheet.put(set); +// return sheet; +// +// } +// +// private class CreatePlantElementAction extends AbstractAction { +// +// @Messages("BTN_create_plant_element=Create New Plant Element...") +// CreatePlantElementAction() { +// super(Bundle.BTN_create_plant_element()); +// } +// +// @Override +// public void actionPerformed(ActionEvent e) { +// // TODO: Implement the creation of a new plant element +// } +// } +//} diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/nodes/Plc4xProjectNodeFactoryImpl.java b/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/nodes/Plc4xProjectNodeFactoryImpl.java new file mode 100644 index 000000000..45d471767 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/nodes/Plc4xProjectNodeFactoryImpl.java @@ -0,0 +1,91 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.malbec.s88.nodes; + +import java.util.ArrayList; +import java.util.List; +import javax.swing.event.ChangeEvent; +import javax.swing.event.ChangeListener; +import org.apache.plc4x.malbec.s88.impl.Plc4xProjectImpl; +import org.netbeans.api.project.Project; +import org.netbeans.spi.project.ui.support.NodeFactory; +import org.netbeans.spi.project.ui.support.NodeList; +import org.openide.nodes.AbstractNode; +import org.openide.nodes.Children; +import org.openide.nodes.FilterNode; +import org.openide.nodes.Node; +import org.openide.util.ChangeSupport; + +@NodeFactory.Registration(projectType = "org-plc4x-s88-project", position = 20) +public class Plc4xProjectNodeFactoryImpl implements NodeFactory { + + @Override + public NodeList createNodes(Project prjct) { + Plc4xProjectImpl p = prjct.getLookup().lookup(Plc4xProjectImpl.class); + assert p != null; + return new Plc4xProjectNodeList(p); + } + + private class Plc4xProjectNodeList implements NodeList, ChangeListener { + private final ChangeSupport cs = new ChangeSupport(this); + private final Plc4xProjectImpl project; + + private Plc4xProjectNodeList(Plc4xProjectImpl project) { + this.project = project; + } + + @Override + public List keys() { + List result = new ArrayList<>(); + return result; + } + + @Override + public void addChangeListener(ChangeListener cl) { + cs.addChangeListener(cl); + } + + @Override + public void removeChangeListener(ChangeListener cl) { + cs.removeChangeListener(cl); + } + + @Override + public Node node(Node k) { + return new FilterNode(k); + } + + @Override + public void addNotify() { + // + } + + @Override + public void removeNotify() { + // + } + + @Override + public void stateChanged(ChangeEvent e) { + cs.fireChange(); + } + + } + +} diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/panels/Plc4xGeneralPropertiesImpl.java b/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/panels/Plc4xGeneralPropertiesImpl.java new file mode 100644 index 000000000..f9c9321fb --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/panels/Plc4xGeneralPropertiesImpl.java @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.malbec.s88.panels; + +import javax.swing.JComponent; +import javax.swing.JPanel; +import org.netbeans.spi.project.ui.support.ProjectCustomizer; +import org.netbeans.spi.project.ui.support.ProjectCustomizer.Category; +import org.openide.util.Lookup; +import org.openide.util.NbBundle; + +public class Plc4xGeneralPropertiesImpl implements ProjectCustomizer.CompositeCategoryProvider { + + private static final String GENERAL = "General"; + + @ProjectCustomizer.CompositeCategoryProvider.Registration(projectType = "org-plc4x-s88-project", position = 10) + public static Plc4xGeneralPropertiesImpl createGeneral() { + return new Plc4xGeneralPropertiesImpl(); + } + + @NbBundle.Messages("LBL_Config_General=General") + @Override + public Category createCategory(Lookup lkp) { + return ProjectCustomizer.Category.create( + GENERAL, + Bundle.LBL_Config_General(), + null); + } + + @Override + public JComponent createComponent(Category category, Lookup lkp) { + return new JPanel(); + } + +} diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/wizard/Plc4xProjectPanelVisual.form b/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/wizard/Plc4xProjectPanelVisual.form new file mode 100644 index 000000000..c6eea480f --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/wizard/Plc4xProjectPanelVisual.form @@ -0,0 +1,143 @@ + + + + +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/wizard/Plc4xProjectPanelVisual.java b/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/wizard/Plc4xProjectPanelVisual.java new file mode 100644 index 000000000..12c22a095 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/wizard/Plc4xProjectPanelVisual.java @@ -0,0 +1,289 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +package org.apache.plc4x.malbec.s88.wizard; + +import java.io.File; +import javax.swing.JFileChooser; +import javax.swing.JPanel; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.text.Document; +import org.netbeans.spi.project.ui.support.ProjectChooser; +import org.openide.WizardDescriptor; +import org.openide.WizardValidationException; +import org.openide.filesystems.FileUtil; + + +import org.openide.util.NbBundle.Messages; + +@Messages({ + "Plc4xProjectPanelVisual.projectNameLabel.text=Project &Name:", + "Plc4xProjectPanelVisual.projectLocationLabel.text=Project &Location:", + "Plc4xProjectPanelVisual.browseButton.text=Br&owse...", + "Plc4xProjectPanelVisual.browseButton.actionCommand=BROWSE", + "Plc4xProjectPanelVisual.createdFolderLabel.text=Project &Folder:", + "LBL_CreateProjectStep=Name and Location" +}) +public class Plc4xProjectPanelVisual extends JPanel implements DocumentListener { + + public static final String PROP_PROJECT_NAME = "projectName"; + + private Plc4xProjectWizardPanel panel; + + public Plc4xProjectPanelVisual(Plc4xProjectWizardPanel panel) { + initComponents(); + this.panel = panel; + projectNameTextField.getDocument().addDocumentListener(this); + projectLocationTextField.getDocument().addDocumentListener(this); + } + + public String getProjectName() { + return this.projectNameTextField.getText(); + } + + /** + * This method is called from within the constructor to initialize the form. + * WARNING: Do NOT modify this code. The content of this method is always + * regenerated by the Form Editor. + */ + // //GEN-BEGIN:initComponents + private void initComponents() { + + projectNameLabel = new javax.swing.JLabel(); + projectNameTextField = new javax.swing.JTextField(); + projectLocationLabel = new javax.swing.JLabel(); + projectLocationTextField = new javax.swing.JTextField(); + browseButton = new javax.swing.JButton(); + createdFolderLabel = new javax.swing.JLabel(); + createdFolderTextField = new javax.swing.JTextField(); + + projectNameLabel.setLabelFor(projectNameTextField); + org.openide.awt.Mnemonics.setLocalizedText(projectNameLabel, org.openide.util.NbBundle.getMessage(Plc4xProjectPanelVisual.class, "Plc4xProjectPanelVisual.projectNameLabel.text")); // NOI18N + + projectLocationLabel.setLabelFor(projectLocationTextField); + org.openide.awt.Mnemonics.setLocalizedText(projectLocationLabel, org.openide.util.NbBundle.getMessage(Plc4xProjectPanelVisual.class, "Plc4xProjectPanelVisual.projectLocationLabel.text")); // NOI18N + + org.openide.awt.Mnemonics.setLocalizedText(browseButton, org.openide.util.NbBundle.getMessage(Plc4xProjectPanelVisual.class, "Plc4xProjectPanelVisual.browseButton.text")); // NOI18N + browseButton.setActionCommand(org.openide.util.NbBundle.getMessage(Plc4xProjectPanelVisual.class, "Plc4xProjectPanelVisual.browseButton.actionCommand")); // NOI18N + browseButton.addActionListener(new java.awt.event.ActionListener() { + public void actionPerformed(java.awt.event.ActionEvent evt) { + browseButtonActionPerformed(evt); + } + }); + + createdFolderLabel.setLabelFor(createdFolderTextField); + org.openide.awt.Mnemonics.setLocalizedText(createdFolderLabel, org.openide.util.NbBundle.getMessage(Plc4xProjectPanelVisual.class, "Plc4xProjectPanelVisual.createdFolderLabel.text")); // NOI18N + + createdFolderTextField.setEditable(false); + + javax.swing.GroupLayout layout = new javax.swing.GroupLayout(this); + this.setLayout(layout); + layout.setHorizontalGroup( + layout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING) + .addGroup(layout.createSequentialGroup() + .addContainerGap() + .addGroup(layout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING) + .addComponent(projectNameLabel) + .addComponent(projectLocationLabel) + .addComponent(createdFolderLabel)) + .addPreferredGap(javax.swing.LayoutStyle.ComponentPlacement.RELATED) + .addGroup(layout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING) + .addComponent(projectNameTextField, javax.swing.GroupLayout.Alignment.TRAILING, javax.swing.GroupLayout.DEFAULT_SIZE, 191, Short.MAX_VALUE) + .addComponent(projectLocationTextField, javax.swing.GroupLayout.Alignment.TRAILING, javax.swing.GroupLayout.DEFAULT_SIZE, 191, Short.MAX_VALUE) + .addComponent(createdFolderTextField, javax.swing.GroupLayout.Alignment.TRAILING, javax.swing.GroupLayout.DEFAULT_SIZE, 191, Short.MAX_VALUE)) + .addPreferredGap(javax.swing.LayoutStyle.ComponentPlacement.RELATED) + .addComponent(browseButton) + .addContainerGap()) + ); + layout.setVerticalGroup( + layout.createParallelGroup(javax.swing.GroupLayout.Alignment.LEADING) + .addGroup(layout.createSequentialGroup() + .addContainerGap() + .addGroup(layout.createParallelGroup(javax.swing.GroupLayout.Alignment.BASELINE) + .addComponent(projectNameLabel) + .addComponent(projectNameTextField, javax.swing.GroupLayout.PREFERRED_SIZE, javax.swing.GroupLayout.DEFAULT_SIZE, javax.swing.GroupLayout.PREFERRED_SIZE)) + .addPreferredGap(javax.swing.LayoutStyle.ComponentPlacement.RELATED) + .addGroup(layout.createParallelGroup(javax.swing.GroupLayout.Alignment.BASELINE) + .addComponent(projectLocationLabel) + .addComponent(projectLocationTextField, javax.swing.GroupLayout.PREFERRED_SIZE, javax.swing.GroupLayout.DEFAULT_SIZE, javax.swing.GroupLayout.PREFERRED_SIZE) + .addComponent(browseButton)) + .addPreferredGap(javax.swing.LayoutStyle.ComponentPlacement.RELATED) + .addGroup(layout.createParallelGroup(javax.swing.GroupLayout.Alignment.BASELINE) + .addComponent(createdFolderLabel) + .addComponent(createdFolderTextField, javax.swing.GroupLayout.PREFERRED_SIZE, javax.swing.GroupLayout.DEFAULT_SIZE, javax.swing.GroupLayout.PREFERRED_SIZE)) + .addContainerGap(213, Short.MAX_VALUE)) + ); + }// //GEN-END:initComponents + + private void browseButtonActionPerformed(java.awt.event.ActionEvent evt) {//GEN-FIRST:event_browseButtonActionPerformed + String command = evt.getActionCommand(); + if ("BROWSE".equals(command)) { + JFileChooser chooser = new JFileChooser(); + chooser.setDialogTitle("Select Project Location"); + chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + String path = this.projectLocationTextField.getText(); + if (path.length() > 0) { + File f = new File(path); + if (f.exists()) { + chooser.setSelectedFile(f); + } + } + if (JFileChooser.APPROVE_OPTION == chooser.showOpenDialog(this)) { + File projectDir = chooser.getSelectedFile(); + projectLocationTextField.setText(FileUtil.normalizeFile(projectDir).getAbsolutePath()); + } + panel.fireChangeEvent(); + } + + }//GEN-LAST:event_browseButtonActionPerformed + + // Variables declaration - do not modify//GEN-BEGIN:variables + private javax.swing.JButton browseButton; + private javax.swing.JLabel createdFolderLabel; + private javax.swing.JTextField createdFolderTextField; + private javax.swing.JLabel projectLocationLabel; + private javax.swing.JTextField projectLocationTextField; + private javax.swing.JLabel projectNameLabel; + private javax.swing.JTextField projectNameTextField; + // End of variables declaration//GEN-END:variables + + @Override + public void addNotify() { + super.addNotify(); + //same problem as in 31086, initial focus on Cancel button + projectNameTextField.requestFocus(); + } + + boolean valid(WizardDescriptor wizardDescriptor) { + + if (projectNameTextField.getText().isEmpty()) { + // TODO if using org.openide.dialogs >= 7.8, can use WizardDescriptor.PROP_ERROR_MESSAGE: + wizardDescriptor.putProperty("WizardPanel_errorMessage", + "Project Name is not a valid folder name."); + return false; // Display name not specified + } + File f = FileUtil.normalizeFile(new File(projectLocationTextField.getText()).getAbsoluteFile()); + if (!f.isDirectory()) { + String message = "Project Folder is not a valid path."; + wizardDescriptor.putProperty("WizardPanel_errorMessage", message); + return false; + } + final File destFolder = FileUtil.normalizeFile(new File(createdFolderTextField.getText()).getAbsoluteFile()); + + File projLoc = destFolder; + while (projLoc != null && !projLoc.exists()) { + projLoc = projLoc.getParentFile(); + } + if (projLoc == null || !projLoc.canWrite()) { + wizardDescriptor.putProperty("WizardPanel_errorMessage", + "Project Folder cannot be created."); + return false; + } + + if (FileUtil.toFileObject(projLoc) == null) { + String message = "Project Folder is not a valid path."; + wizardDescriptor.putProperty("WizardPanel_errorMessage", message); + return false; + } + + File[] kids = destFolder.listFiles(); + if (destFolder.exists() && kids != null && kids.length > 0) { + // Folder exists and is not empty + wizardDescriptor.putProperty("WizardPanel_errorMessage", + "Project Folder already exists and is not empty."); + return false; + } + wizardDescriptor.putProperty("WizardPanel_errorMessage", ""); + return true; + } + + void store(WizardDescriptor d) { + String name = projectNameTextField.getText().trim(); + String folder = createdFolderTextField.getText().trim(); + + d.putProperty("projdir", new File(folder)); + d.putProperty("name", name); + } + + void read(WizardDescriptor settings) { + File projectLocation = (File) settings.getProperty("projdir"); + if (projectLocation == null || projectLocation.getParentFile() == null || !projectLocation.getParentFile().isDirectory()) { + projectLocation = ProjectChooser.getProjectsFolder(); + } else { + projectLocation = projectLocation.getParentFile(); + } + this.projectLocationTextField.setText(projectLocation.getAbsolutePath()); + + String projectName = (String) settings.getProperty("name"); + if (projectName == null) { + projectName = "Batch Project"; + } + this.projectNameTextField.setText(projectName); + this.projectNameTextField.selectAll(); + } + + void validate(WizardDescriptor d) throws WizardValidationException { + // nothing to validate + } + + // Implementation of DocumentListener -------------------------------------- + public void changedUpdate(DocumentEvent e) { + updateTexts(e); + if (this.projectNameTextField.getDocument() == e.getDocument()) { + firePropertyChange(PROP_PROJECT_NAME, null, this.projectNameTextField.getText()); + } + } + + public void insertUpdate(DocumentEvent e) { + updateTexts(e); + if (this.projectNameTextField.getDocument() == e.getDocument()) { + firePropertyChange(PROP_PROJECT_NAME, null, this.projectNameTextField.getText()); + } + } + + public void removeUpdate(DocumentEvent e) { + updateTexts(e); + if (this.projectNameTextField.getDocument() == e.getDocument()) { + firePropertyChange(PROP_PROJECT_NAME, null, this.projectNameTextField.getText()); + } + } + + /** + * Handles changes in the Project name and project directory, + */ + private void updateTexts(DocumentEvent e) { + + Document doc = e.getDocument(); + + if (doc == projectNameTextField.getDocument() || doc == projectLocationTextField.getDocument()) { + // Change in the project name + + String projectName = projectNameTextField.getText(); + String projectFolder = projectLocationTextField.getText(); + + //if (projectFolder.trim().length() == 0 || projectFolder.equals(oldName)) { + createdFolderTextField.setText(projectFolder + File.separatorChar + projectName); + //} + + } + panel.fireChangeEvent(); // Notify that the panel changed + } + +} diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/wizard/Plc4xProjectWizardPanel.java b/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/wizard/Plc4xProjectWizardPanel.java new file mode 100644 index 000000000..f4e6ae237 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/wizard/Plc4xProjectWizardPanel.java @@ -0,0 +1,112 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.malbec.s88.wizard; + +import java.awt.Component; +import java.util.HashSet; +import java.util.Set; +import javax.swing.event.ChangeEvent; +import javax.swing.event.ChangeListener; +import org.openide.WizardDescriptor; +import org.openide.WizardValidationException; +import org.openide.util.HelpCtx; + +/** + * Panel just asking for basic info. + */ +public class Plc4xProjectWizardPanel implements WizardDescriptor.Panel, + WizardDescriptor.ValidatingPanel, WizardDescriptor.FinishablePanel { + + private WizardDescriptor wizardDescriptor; + private Plc4xProjectPanelVisual component; + + public Plc4xProjectWizardPanel() { + } + + @Override + public Component getComponent() { + if (component == null) { + component = new Plc4xProjectPanelVisual(this); + component.setName(Bundle.LBL_CreateProjectStep()); + } + return component; + } + + @Override + public HelpCtx getHelp() { + return new HelpCtx(Plc4xProjectWizardPanel.class); + } + + @Override + public boolean isValid() { + getComponent(); + return component.valid(wizardDescriptor); + } + + private final Set listeners = new HashSet<>(1); // or can use ChangeSupport in NB 6.0 + + @Override + public final void addChangeListener(ChangeListener l) { + synchronized (listeners) { + listeners.add(l); + } + } + + @Override + public final void removeChangeListener(ChangeListener l) { + synchronized (listeners) { + listeners.remove(l); + } + } + + protected final void fireChangeEvent() { + Set ls; + synchronized (listeners) { + ls = new HashSet<>(listeners); + } + ChangeEvent ev = new ChangeEvent(this); + for (ChangeListener l : ls) { + l.stateChanged(ev); + } + } + + @Override + public void readSettings(Object settings) { + wizardDescriptor = (WizardDescriptor) settings; + component.read(wizardDescriptor); + } + + @Override + public void storeSettings(Object settings) { + WizardDescriptor d = (WizardDescriptor) settings; + component.store(d); + } + + @Override + public boolean isFinishPanel() { + return true; + } + + @Override + public void validate() throws WizardValidationException { + getComponent(); + component.validate(wizardDescriptor); + } + +} diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/wizard/Plc4xS88ProjectWizardIterator.java b/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/wizard/Plc4xS88ProjectWizardIterator.java new file mode 100644 index 000000000..2fff412ae --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/project/src/main/java/org/apache/plc4x/malbec/s88/wizard/Plc4xS88ProjectWizardIterator.java @@ -0,0 +1,240 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.malbec.s88.wizard; + +import java.awt.Component; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.text.MessageFormat; +import java.util.LinkedHashSet; +import java.util.NoSuchElementException; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import javax.swing.JComponent; +import javax.swing.event.ChangeListener; +import org.netbeans.api.templates.TemplateRegistration; +import org.netbeans.spi.project.ui.support.ProjectChooser; +import org.netbeans.spi.project.ui.templates.support.Templates; +import org.openide.WizardDescriptor; +import org.openide.filesystems.FileObject; +import org.openide.filesystems.FileUtil; +import org.openide.util.Exceptions; +import org.openide.util.NbBundle; +import org.openide.util.NbBundle.Messages; +import org.openide.xml.XMLUtil; +import org.w3c.dom.DOMException; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; + +@TemplateRegistration( + folder = "Project/Industrial", + displayName = "#Plc4xS88Project_displayName", + description = "Plc4xProjectDescription.html", + iconBase = "org/apache/plc4x/malbec/s88/wizard/Plc4xProject.png", + content = "Project.zip", + position = 100) +@Messages("Plc4xS88Project_displayName=Batch Production Project") +public class Plc4xS88ProjectWizardIterator implements WizardDescriptor./*Progress*/InstantiatingIterator { + + private int index; + private WizardDescriptor.Panel[] panels; + private WizardDescriptor wiz; + + public Plc4xS88ProjectWizardIterator() { + } + + public static Plc4xS88ProjectWizardIterator createIterator() { + return new Plc4xS88ProjectWizardIterator(); + } + + private WizardDescriptor.Panel[] createPanels() { + return new WizardDescriptor.Panel[]{ + new Plc4xProjectWizardPanel(),}; + } + + private String[] createSteps() { + return new String[]{ + NbBundle.getMessage(Plc4xS88ProjectWizardIterator.class, "LBL_CreateProjectStep") + }; + } + + @Override + public Set/**/ instantiate(/*ProgressHandle handle*/) throws IOException { + Set resultSet = new LinkedHashSet<>(); + File dirF = FileUtil.normalizeFile((File) wiz.getProperty("projdir")); + dirF.mkdirs(); + + FileObject template = Templates.getTemplate(wiz); + FileObject dir = FileUtil.toFileObject(dirF); + unZipFile(template.getInputStream(), dir); + + if (dir.getFileObject("plant.cfg") == null) { + dir.createData("plant.cfg"); + } + + resultSet.add(dir); + + File parent = dirF.getParentFile(); + if (parent != null && parent.exists()) { + ProjectChooser.setProjectsFolder(parent); + } + + return resultSet; + } + + @Override + public void initialize(WizardDescriptor wiz) { + this.wiz = wiz; + index = 0; + panels = createPanels(); + // Make sure list of steps is accurate. + String[] steps = createSteps(); + for (int i = 0; i < panels.length; i++) { + Component c = panels[i].getComponent(); + if (steps[i] == null) { + // Default step name to component name of panel. + // Mainly useful for getting the name of the target + // chooser to appear in the list of steps. + steps[i] = c.getName(); + } + if (c instanceof JComponent jc) { // Step #. + // TODO if using org.openide.dialogs >= 7.8, can use WizardDescriptor.PROP_*: + jc.putClientProperty("WizardPanel_contentSelectedIndex", i); + // Step name (actually the whole list for reference). + jc.putClientProperty("WizardPanel_contentData", steps); + } + } + } + + @Override + public void uninitialize(WizardDescriptor wiz) { + this.wiz.putProperty("projdir", null); + this.wiz.putProperty("name", null); + this.wiz = null; + panels = null; + } + + @Override + public String name() { + return MessageFormat.format("{0} of {1}", + new Object[]{index + 1, panels.length}); + } + + @Override + public boolean hasNext() { + return index < panels.length - 1; + } + + @Override + public boolean hasPrevious() { + return index > 0; + } + + @Override + public void nextPanel() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + index++; + } + + @Override + public void previousPanel() { + if (!hasPrevious()) { + throw new NoSuchElementException(); + } + index--; + } + + @Override + public WizardDescriptor.Panel current() { + return panels[index]; + } + + @Override + public final void addChangeListener(ChangeListener l) { + } + + @Override + public final void removeChangeListener(ChangeListener l) { + } + + private static void unZipFile(InputStream source, FileObject projectRoot) throws IOException { + try (source) { + ZipInputStream str = new ZipInputStream(source); + ZipEntry entry; + while ((entry = str.getNextEntry()) != null) { + if (entry.isDirectory()) { + FileUtil.createFolder(projectRoot, entry.getName()); + } else { + FileObject fo = FileUtil.createData(projectRoot, entry.getName()); + if ("nbproject/project.xml".equals(entry.getName())) { + // Special handling for setting name of Ant-based projects; customize as needed: + filterProjectXML(fo, str, projectRoot.getName()); + } else { + writeFile(str, fo); + } + } + } + } + } + + private static void writeFile(ZipInputStream str, FileObject fo) throws IOException { + try (OutputStream out = fo.getOutputStream()) { + FileUtil.copy(str, out); + } + } + + private static void filterProjectXML(FileObject fo, ZipInputStream str, String name) throws IOException { + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + FileUtil.copy(str, baos); + Document doc = XMLUtil.parse(new InputSource(new ByteArrayInputStream(baos.toByteArray())), false, false, null, null); + NodeList nl = doc.getDocumentElement().getElementsByTagName("name"); + if (nl != null) { + for (int i = 0; i < nl.getLength(); i++) { + Element el = (Element) nl.item(i); + if (el.getParentNode() != null && "data".equals(el.getParentNode().getNodeName())) { + NodeList nl2 = el.getChildNodes(); + if (nl2.getLength() > 0) { + nl2.item(0).setNodeValue(name); + } + break; + } + } + } + try (OutputStream out = fo.getOutputStream()) { + XMLUtil.write(doc, out, "UTF-8"); + } + } catch (IOException | DOMException | SAXException ex) { + Exceptions.printStackTrace(ex); + writeFile(str, fo); + } + + } + +} diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/nbm/manifest.mf b/plc4j/tools/malbec/malbec-s88/project/src/main/nbm/manifest.mf new file mode 100644 index 000000000..a9ff70c77 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/project/src/main/nbm/manifest.mf @@ -0,0 +1,4 @@ +Manifest-Version: 1.0 +OpenIDE-Module-Localizing-Bundle: org/apache/plc4x/malbec/s88/type/Bundle.properties +OpenIDE-Module-Requires: org.openide.windows.WindowManager + diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/Bundle.properties b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/Bundle.properties new file mode 100644 index 000000000..51e04edae --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/Bundle.properties @@ -0,0 +1,23 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +#Localized module labels. Defaults taken from POM (, , ) if unset. +#OpenIDE-Module-Name= +#OpenIDE-Module-Short-Description= +#OpenIDE-Module-Long-Description= +#OpenIDE-Module-Display-Category= +#Wed Jan 14 13:56:33 GMT-04:00 2026 diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/impl/Bundle.properties b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/impl/Bundle.properties new file mode 100644 index 000000000..51e04edae --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/impl/Bundle.properties @@ -0,0 +1,23 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +#Localized module labels. Defaults taken from POM (, , ) if unset. +#OpenIDE-Module-Name= +#OpenIDE-Module-Short-Description= +#OpenIDE-Module-Long-Description= +#OpenIDE-Module-Display-Category= +#Wed Jan 14 13:56:33 GMT-04:00 2026 diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/impl/Project.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/impl/Project.png new file mode 100644 index 000000000..a50403a7f Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/impl/Project.png differ diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/AgregarEstructura.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/AgregarEstructura.png new file mode 100644 index 000000000..0f8f4dd8f Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/AgregarEstructura.png differ diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/AjusteDeAvisos.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/AjusteDeAvisos.png new file mode 100644 index 000000000..902d2c26f Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/AjusteDeAvisos.png differ diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/ArchivosDeDatos.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/ArchivosDeDatos.png new file mode 100644 index 000000000..b18a9f2bc Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/ArchivosDeDatos.png differ diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/AvisosAnalogicos.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/AvisosAnalogicos.png new file mode 100644 index 000000000..714b9d212 Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/AvisosAnalogicos.png differ diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/AvisosBit.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/AvisosBit.png new file mode 100644 index 000000000..b41739784 Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/AvisosBit.png differ diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/Bundle.properties b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/Bundle.properties new file mode 100644 index 000000000..51e04edae --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/Bundle.properties @@ -0,0 +1,23 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +#Localized module labels. Defaults taken from POM (, , ) if unset. +#OpenIDE-Module-Name= +#OpenIDE-Module-Short-Description= +#OpenIDE-Module-Long-Description= +#OpenIDE-Module-Display-Category= +#Wed Jan 14 13:56:33 GMT-04:00 2026 diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/Ciclos.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/Ciclos.png new file mode 100644 index 000000000..d42811cc1 Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/Ciclos.png differ diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/ClasesDeAvisos.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/ClasesDeAvisos.png new file mode 100644 index 000000000..e272fbe16 Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/ClasesDeAvisos.png differ diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/Conexiones.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/Conexiones.png new file mode 100644 index 000000000..b23170e8e Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/Conexiones.png differ diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/ConfiguracionDeUsuariosSeguridadRuntime.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/ConfiguracionDeUsuariosSeguridadRuntime.png new file mode 100644 index 000000000..c5c30de85 Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/ConfiguracionDeUsuariosSeguridadRuntime.png differ diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/ConfiguracionDelPanel.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/ConfiguracionDelPanel.png new file mode 100644 index 000000000..9a1c812bd Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/ConfiguracionDelPanel.png differ diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/ConfiguracionParaNavegacionDeImagenes.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/ConfiguracionParaNavegacionDeImagenes.png new file mode 100644 index 000000000..03b1fea54 Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/ConfiguracionParaNavegacionDeImagenes.png differ diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/Diccionario.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/Diccionario.png new file mode 100644 index 000000000..13d9d8a3d Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/Diccionario.png differ diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/Estructura.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/Estructura.png new file mode 100644 index 000000000..2724a3593 Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/Estructura.png differ diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/FicheroDeAvisos.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/FicheroDeAvisos.png new file mode 100644 index 000000000..7d4c2d0a4 Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/FicheroDeAvisos.png differ diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/FolderBlue-Open.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/FolderBlue-Open.png new file mode 100644 index 000000000..2225cb0ca Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/FolderBlue-Open.png differ diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/FolderBlue.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/FolderBlue.png new file mode 100644 index 000000000..91800c1bd Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/FolderBlue.png differ diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/Graficos.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/Graficos.png new file mode 100644 index 000000000..40797a21f Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/Graficos.png differ diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/Grupos.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/Grupos.png new file mode 100644 index 000000000..f6eca3ba4 Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/Grupos.png differ diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/GruposDeAvisos.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/GruposDeAvisos.png new file mode 100644 index 000000000..88e3b6fe0 Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/GruposDeAvisos.png differ diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/IdiomasDelProyecto.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/IdiomasDelProyecto.png new file mode 100644 index 000000000..361f983cc Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/IdiomasDelProyecto.png differ diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/InformacionDelCambio.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/InformacionDelCambio.png new file mode 100644 index 000000000..620866d76 Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/InformacionDelCambio.png differ diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/ListaDeGraficoa.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/ListaDeGraficoa.png new file mode 100644 index 000000000..187559022 Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/ListaDeGraficoa.png differ diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/ListaDeTexto.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/ListaDeTexto.png new file mode 100644 index 000000000..d59cff8aa Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/ListaDeTexto.png differ diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/NavegacionDeImagenes.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/NavegacionDeImagenes.png new file mode 100644 index 000000000..d1427659c Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/NavegacionDeImagenes.png differ diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/PlanificadorDeTareas.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/PlanificadorDeTareas.png new file mode 100644 index 000000000..8f4fdb304 Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/PlanificadorDeTareas.png differ diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/Receta_1.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/Receta_1.png new file mode 100644 index 000000000..e560917d1 Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/Receta_1.png differ diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/TextosDelPRoyecto.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/TextosDelPRoyecto.png new file mode 100644 index 000000000..7480f7e09 Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/TextosDelPRoyecto.png differ diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/Usuarios.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/Usuarios.png new file mode 100644 index 000000000..055f4a245 Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/Usuarios.png differ diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/Variables.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/Variables.png new file mode 100644 index 000000000..6778f6be8 Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/Variables.png differ diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/VersionesProyecto.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/VersionesProyecto.png new file mode 100644 index 000000000..153eef813 Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/nodes/VersionesProyecto.png differ diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/type/Bundle.properties b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/type/Bundle.properties new file mode 100644 index 000000000..74d8a651f --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/type/Bundle.properties @@ -0,0 +1,23 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +#Localized module labels. Defaults taken from POM (, , ) if unset. +#OpenIDE-Module-Name= +#OpenIDE-Module-Short-Description= +#OpenIDE-Module-Long-Description= +#OpenIDE-Module-Display-Category= +#Tue May 05 14:56:36 VET 2026 diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/type/PLc4xBobFileTypeTemplate.bob b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/type/PLc4xBobFileTypeTemplate.bob new file mode 100644 index 000000000..395243ce1 --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/type/PLc4xBobFileTypeTemplate.bob @@ -0,0 +1,51 @@ + + + + + Label + TITLE + My Display + 0 + 0 + 550 + 31 + + + + + + + + + true + + + Label_1 + Some Value: + 50 + + + Text Update + sim://sine + 100 + 50 + + + Label_2 + COMMENT + Some comment. + 220 + 50 + 140 + + + + + + + + + true + true + + diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/wizard/Bundle.properties b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/wizard/Bundle.properties new file mode 100644 index 000000000..80a0ff85f --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/wizard/Bundle.properties @@ -0,0 +1,23 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. + +#LBL_CreateProjectStep=Name and Location +#Plc4xProjectPanelVisual.projectNameLabel.text=Project &Name: +#Plc4xProjectPanelVisual.createdFolderLabel.text=Project &Folder: +#Plc4xProjectPanelVisual.browseButton.actionCommand=BROWSE +#Plc4xProjectPanelVisual.browseButton.text=Br&owse... +#Plc4xProjectPanelVisual.projectLocationLabel.text=Project &Location: diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/wizard/Plc4xProject.png b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/wizard/Plc4xProject.png new file mode 100644 index 000000000..8b161292a Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/wizard/Plc4xProject.png differ diff --git a/plc4j/tools/ui/frontend/project/index.html b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/wizard/Plc4xProjectDescription.html similarity index 68% rename from plc4j/tools/ui/frontend/project/index.html rename to plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/wizard/Plc4xProjectDescription.html index abc30c4b2..453aedd23 100644 --- a/plc4j/tools/ui/frontend/project/index.html +++ b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/wizard/Plc4xProjectDescription.html @@ -15,18 +15,13 @@ KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. - --> +--> - - - - - - - Vite + React + TS - - -
- - + + + + + + Sample S88 project + diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/wizard/Project.zip b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/wizard/Project.zip new file mode 100644 index 000000000..b3590795f --- /dev/null +++ b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/wizard/Project.zip @@ -0,0 +1,16 @@ +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you 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. diff --git a/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/wizard/S88Project.zip b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/wizard/S88Project.zip new file mode 100644 index 000000000..260d12803 Binary files /dev/null and b/plc4j/tools/malbec/malbec-s88/project/src/main/resources/org/apache/plc4x/malbec/s88/wizard/S88Project.zip differ diff --git a/plc4j/tools/malbec/nb-configuration.xml b/plc4j/tools/malbec/nb-configuration.xml index 6dee97932..c42d38659 100644 --- a/plc4j/tools/malbec/nb-configuration.xml +++ b/plc4j/tools/malbec/nb-configuration.xml @@ -14,7 +14,6 @@ That way multiple projects can share the same settings (useful for formatting ru Any value defined here will override the pom.xml file value but is only applicable to the current project. --> [{"repository":"php","rule":"S3335"},{"repository":"php","rule":"S3336"},{"repository":"php","rule":"S2002"},{"repository":"php","rule":"S2005"},{"repository":"php","rule":"S3333"},{"repository":"php","rule":"S3334"},{"repository":"Web","rule":"ImgWithoutWidthOrHeightCheck"},{"repository":"php","rule":"S1151"},{"repository":"php","rule":"S3332"},{"repository":"php","rule":"S2001"},{"repository":"Web","rule":"UnclosedTagCheck"},{"repository":"php","rule":"S2000"},{"repository":"java","rule":"S2309"},{"repository":"php","rule":"S126"},{"repository":"java","rule":"S4605"},{"repository":"java","rule":"S4604"},{"repository":"java","rule":"S2308"},{"repository":"xml","rule":"S3282"},{"repository":"php","rule":"S2918"},{"repository":"java","rule":"S2301"},{"repository":"java","rule":"S1696"},{"repository":"java","rule":"S1213"},{"repository":"java","rule":"S1698"},{"repository":"java","rule":"S1699"},{"repository":"php","rule":"S1820"},{"repository":"java","rule":"S3750"},{"repository":"java","rule":"S4288"},{"repository":"java","rule":"S1451"},{"repository":"php","rule":"S122"},{"repository":"java","rule":"S1694"},{"repository":"java","rule":"S1695"},{"repository":"php","rule":"S1821"},{"repository":"php","rule":"S5867"},{"repository":"java","rule":"S6211"},{"repository":"java","rule":"S2096"},{"repository":"java","rule":"S6212"},{"repository":"php","rule":"S2011"},{"repository":"Web","rule":"UnifiedExpressionCheck"},{"repository":"Web","rule":"PageWithoutFaviconCheck"},{"repository":"php","rule":"S134"},{"repository":"java","rule":"S1448"},{"repository":"java","rule":"S2658"},{"repository":"java","rule":"S1449"},{"repository":"java","rule":"S3749"},{"repository":"php","rule":"S139"},{"repository":"java","rule":"S5128"},{"repository":"php","rule":"S2007"},{"repository":"php","rule":"S3337"},{"repository":"php","rule":"S3338"},{"repository":"java","rule":"S1200"},{"repository":"Web","rule":"ComplexityCheck"},{"repository":"Web","rule":"RequiredAttributeCheck"},{"repository":"php","rule":"S104"},{"repository":"java","rule":"S3658"},{"repository":"java","rule":"S2208"},{"repository":"java","rule":"S2444"},{"repository":"java","rule":"S2203"},{"repository":"java","rule":"S2325"},{"repository":"java","rule":"S3414"},{"repository":"Web","rule":"S1829"},{"repository":"java","rule":"S4174"},{"repository":"Web","rule":"IllegalTabCheck"},{"repository":"java","rule":"S1106"},{"repository":"java","rule":"S1228"},{"repository":"java","rule":"S1107"},{"repository":"java","rule":"S1108"},{"repository":"java","rule":"S1109"},{"repository":"xml","rule":"S3373"},{"repository":"java","rule":"S1105"},{"repository":"php","rule":"S5856"},{"repository":"java","rule":"S2063"},{"repository":"java","rule":"S3030"},{"repository":"java","rule":"S3032"},{"repository":"Web","rule":"DoubleQuotesCheck"},{"repository":"Web","rule":"MaxLineLengthCheck"},{"repository":"java","rule":"S105"},{"repository":"Web","rule":"LongJavaScriptCheck"},{"repository":"java","rule":"S103"},{"repository":"Web","rule":"WmodeIsWindowCheck"},{"repository":"java","rule":"S104"},{"repository":"java","rule":"S109"},{"repository":"java","rule":"S113"},{"repository":"java","rule":"S4926"},{"repository":"java","rule":"S4248"},{"repository":"java","rule":"S1774"},{"repository":"php","rule":"S1105"},{"repository":"php","rule":"S1106"},{"repository":"php","rule":"S1121"},{"repository":"Web","rule":"InputWithoutLabelCheck"},{"repository":"xml","rule":"S2260"},{"repository":"java","rule":"S2059"},{"repository":"java","rule":"S1641"},{"repository":"java","rule":"S2972"},{"repository":"java","rule":"S2973"},{"repository":"java","rule":"S2974"},{"repository":"java","rule":"S2057"},{"repository":"java","rule":"S6411"},{"repository":"php","rule":"S1117"},{"repository":"java","rule":"S3052"},{"repository":"php","rule":"S1451"},{"repository":"Web","rule":"NonConsecutiveHeadingCheck"},{"repository":"java","rule":"S126"},{"repository":"java","rule":"S923"},{"repository":"java","rule":"S134"},{"repository":"java","rule":"S1315"},{"repository":"java","rule":"S1312"},{"repository":"java","rule":"S1314"},{"repository":"java","rule":"S4266"},{"repository":"java","rule":"S1310"},{"repository":"java","rule":"S2196"},{"repository":"java","rule":"S2197"},{"repository":"java","rule":"S118"},{"repository":"java","rule":"S1309"},{"repository":"java","rule":"S3725"},{"repository":"java","rule":"S121"},{"repository":"java","rule":"S122"},{"repository":"Web","rule":"DynamicJspIncludeCheck"},{"repository":"php","rule":"S1578"},{"repository":"php","rule":"S5935"},{"repository":"Web","rule":"InlineStyleCheck"},{"repository":"java","rule":"S3047"},{"repository":"java","rule":"S1541"},{"repository":"java","rule":"S2260"},{"repository":"java","rule":"S2141"},{"repository":"php","rule":"S1311"},{"repository":"java","rule":"S2384"},{"repository":"php","rule":"S4142"},{"repository":"xml","rule":"S105"},{"repository":"java","rule":"S2701"},{"repository":"xml","rule":"S103"},{"repository":"java","rule":"S2148"},{"repository":"php","rule":"S881"},{"repository":"java","rule":"S2143"},{"repository":"java","rule":"S1176"},{"repository":"java","rule":"S1160"},{"repository":"php","rule":"S1200"},{"repository":"java","rule":"S2250"},{"repository":"java","rule":"S818"},{"repository":"java","rule":"S1162"},{"repository":"java","rule":"S4551"},{"repository":"java","rule":"S2131"},{"repository":"java","rule":"S138"},{"repository":"java","rule":"S139"},{"repository":"Web","rule":"IllegalNamespaceCheck"},{"repository":"php","rule":"S5915"},{"repository":"php","rule":"S1314"},{"repository":"java","rule":"S1166"},{"repository":"php","rule":"S1799"},{"repository":"java","rule":"S5793"},{"repository":"java","rule":"S1194"},{"repository":"java","rule":"S2162"},{"repository":"java","rule":"S2164"},{"repository":"xml","rule":"S3419"},{"repository":"Web","rule":"WhiteSpaceAroundCheck"},{"repository":"java","rule":"S3937"},{"repository":"xml","rule":"S1120"},{"repository":"java","rule":"S1996"},{"repository":"Web","rule":"MultiplePageDirectivesCheck"},{"repository":"xml","rule":"S3420"},{"repository":"java","rule":"S3254"},{"repository":"xml","rule":"S3423"},{"repository":"java","rule":"S2047"},{"repository":"php","rule":"S1541"},{"repository":"Web","rule":"InternationalizationCheck"},{"repository":"java","rule":"S3242"},{"repository":"java","rule":"S6073"},{"repository":"php","rule":"S2070"},{"repository":"java","rule":"S2959"},{"repository":"Web","rule":"LinkToNothingCheck"},{"repository":"Web","rule":"S1436"},{"repository":"java","rule":"S2039"},{"repository":"xml","rule":"S2321"},{"repository":"java","rule":"S1188"},{"repository":"java","rule":"S1067"},{"repository":"java","rule":"S2156"},{"repository":"java","rule":"S3366"},{"repository":"Web","rule":"LinksIdenticalTextsDifferentTargetsCheck"},{"repository":"php","rule":"S2047"},{"repository":"php","rule":"S2046"},{"repository":"java","rule":"S5970"},{"repository":"php","rule":"S2043"},{"repository":"php","rule":"S2042"},{"repository":"php","rule":"S1990"},{"repository":"php","rule":"S2044"},{"repository":"java","rule":"S864"},{"repository":"Web","rule":"MouseEventWithoutKeyboardEquivalentCheck"},{"repository":"java","rule":"S5979"},{"repository":"java","rule":"NoSonar"},{"repository":"java","rule":"S5977"},{"repository":"java","rule":"S5612"},{"repository":"java","rule":"S1258"},{"repository":"java","rule":"S3437"},{"repository":"Web","rule":"FileLengthCheck"},{"repository":"Web","rule":"JspScriptletCheck"},{"repository":"java","rule":"S2221"},{"repository":"java","rule":"S1132"},{"repository":"java","rule":"S3553"},{"repository":"php","rule":"NoSonar"},{"repository":"Web","rule":"IllegalTagLibsCheck"},{"repository":"php","rule":"S2050"},{"repository":"java","rule":"S3306"},{"repository":"java","rule":"S2698"},{"repository":"php","rule":"S1996"},{"repository":"php","rule":"S2964"},{"repository":"java","rule":"S2693"},{"repository":"java","rule":"S1120"},{"repository":"java","rule":"S2694"},{"repository":"java","rule":"S2211"},{"repository":"php","rule":"S1997"},{"repository":"java","rule":"S2333"},{"repository":"java","rule":"S1244"},{"repository":"php","rule":"S5899"},{"repository":"java","rule":"S1151"},{"repository":"Web","rule":"IllegalElementCheck"},{"repository":"java","rule":"S5194"},{"repository":"php","rule":"S2260"},{"repository":"java","rule":"S888"},{"repository":"java","rule":"S1711"},{"repository":"java","rule":"S3578"},{"repository":"php","rule":"S2278"},{"repository":"php","rule":"S2036"},{"repository":"php","rule":"S2277"},{"repository":"php","rule":"S1067"},{"repository":"php","rule":"S2830"},{"repository":"php","rule":"S2038"},{"repository":"php","rule":"S2037"},{"repository":"php","rule":"S5783"},{"repository":"java","rule":"S1939"},{"repository":"java","rule":"S1821"},{"repository":"java","rule":"S1942"},{"repository":"java","rule":"S1943"},{"repository":"java","rule":"S881"},{"repository":"java","rule":"S5867"},{"repository":"java","rule":"S1147"},{"repository":"java","rule":"S1820"},{"repository":"java","rule":"S1941"},{"repository":"java","rule":"S1142"},{"repository":"php","rule":"S2701"},{"repository":"Web","rule":"HeaderCheck"}] - JDK_21__System_ global {} true diff --git a/plc4j/tools/malbec/pom.xml b/plc4j/tools/malbec/pom.xml index 1f1105e87..8f675d867 100644 --- a/plc4j/tools/malbec/pom.xml +++ b/plc4j/tools/malbec/pom.xml @@ -27,19 +27,19 @@ org.apache.plc4x malbec-parent - 0.13.0-SNAPSHOT + 1.1.0-SNAPSHOT pom ${project.groupId}:${project.artifactId} - RELEASE280 + RELEASE310 malbec - 11 + 21 - - 0.13.1 + + 1.0.0 UTF-8 UTF-8 @@ -103,7 +103,7 @@ 1.5.6 3.2.0 0.6.13 - 5.12.0 + 5.18.0 4.1.111.Final 1.8.2 2.0.13 @@ -130,6 +130,11 @@ + + org.apache.rat + apache-rat-plugin + 0.16.1 + org.apache.netbeans.utilities nbm-maven-plugin @@ -140,6 +145,12 @@ ${brandingToken} + + org.apache.maven.plugins + maven-surefire-plugin + 3.3.0 + + org.apache.maven.plugins maven-compiler-plugin @@ -155,6 +166,11 @@ maven-jar-plugin 3.3.0 + + org.apache.maven.plugins + maven-resources-plugin + 3.5.0 + org.apache.maven.plugins maven-dependency-plugin @@ -193,6 +209,40 @@ + + + org.apache.rat + apache-rat-plugin + + + license-check + verify + + check + + + + + true + true + + **/target/** + out/** + **/.git/** + **/.gitignore + **/.idea/** + **/.vscode/** + **/nb-configuration.xml + **/*.xsd + **/*.axml + **/*.form + **/*.mf + **/*.bob + **/src/test/resources/** + + + + @@ -201,6 +251,7 @@ malbec-core malbec-plc4x malbec-projecttype + malbec-s88
diff --git a/plc4j/tools/opcua-server/pom.xml b/plc4j/tools/opcua-server/pom.xml index 66723457e..1b8f00d52 100644 --- a/plc4j/tools/opcua-server/pom.xml +++ b/plc4j/tools/opcua-server/pom.xml @@ -17,13 +17,12 @@ specific language governing permissions and limitations under the License. --> - - 4.0.0 + - org.apache.plc4x + org.apache.plc4x.extras plc4j-tools - 0.13.0-SNAPSHOT + 1.0.0-SNAPSHOT plc4x-opcua-server @@ -31,7 +30,7 @@ PLC4J: Tools: OPC-UA Server - 2024-02-16T14:53:02Z + 2026-09-20T15:22:10Z org.apache.plc4x.java.opcuaserver.OPCUAServer @@ -40,82 +39,82 @@ org.eclipse.milo dictionary-manager - 0.6.14 + 0.6.16 org.eclipse.milo sdk-server - 0.6.13 + 0.6.16 org.eclipse.milo stack-server - 0.6.13 + 0.6.16 org.eclipse.milo stack-core - 0.6.14 + 0.6.16 org.eclipse.milo sdk-core - 0.6.13 + 0.6.16 com.fasterxml.jackson.dataformat jackson-dataformat-yaml - 2.17.2 + 2.22.1 com.fasterxml.jackson.core jackson-databind - 2.17.2 + 2.22.1 com.fasterxml.jackson.core jackson-annotations - 2.17.2 + 2.22 commons-cli commons-cli - 1.8.0 + 1.11.0 org.eclipse.jetty jetty-util - 12.0.12 + 12.1.11 commons-codec commons-codec - 1.17.1 + 1.22.0 org.apache.plc4x plc4j-api - 0.13.0-SNAPSHOT + ${plc4x.version} org.apache.plc4x - plc4j-connection-cache - 0.13.0-SNAPSHOT + plc4j-tools-connection-cache + ${plc4x.version} org.apache.plc4x plc4j-driver-all - 0.13.0-SNAPSHOT + ${plc4x.version} pom runtime @@ -123,46 +122,46 @@ org.bouncycastle bcmail-jdk18on - 1.78.1 + 1.84 org.bouncycastle bcpkix-jdk18on - 1.78.1 + 1.84 org.bouncycastle bcprov-jdk18on - 1.78.1 + 1.84 org.slf4j slf4j-api - 2.0.13 + 2.0.18 ch.qos.logback logback-classic - 1.5.6 + 1.5.38 commons-io commons-io - 2.16.1 + 2.22.0 io.vavr vavr - 0.10.4 + 1.0.1 org.junit.jupiter junit-jupiter-api - 5.10.3 + 6.1.1 test diff --git a/plc4j/tools/opcua-server/src/main/java/org/apache/plc4x/java/opcuaserver/backend/Plc4xCommunication.java b/plc4j/tools/opcua-server/src/main/java/org/apache/plc4x/java/opcuaserver/backend/Plc4xCommunication.java index 30eac9d67..76cf0553a 100644 --- a/plc4j/tools/opcua-server/src/main/java/org/apache/plc4x/java/opcuaserver/backend/Plc4xCommunication.java +++ b/plc4j/tools/opcua-server/src/main/java/org/apache/plc4x/java/opcuaserver/backend/Plc4xCommunication.java @@ -27,7 +27,7 @@ import org.apache.plc4x.java.api.model.PlcTag; import org.apache.plc4x.java.api.types.PlcResponseCode; import org.apache.plc4x.java.api.types.PlcValueType; -import org.apache.plc4x.java.utils.cache.CachedPlcConnectionManager; +import org.apache.plc4x.java.utils.cache.PlcConnectionCache; import org.eclipse.milo.opcua.sdk.server.AbstractLifecycle; import org.eclipse.milo.opcua.sdk.server.api.DataItem; import org.eclipse.milo.opcua.sdk.server.nodes.filters.AttributeFilterContext; @@ -55,7 +55,7 @@ public class Plc4xCommunication extends AbstractLifecycle { private PlcDriverManager driverManager; - private CachedPlcConnectionManager cachedPlcConnectionManager; + private PlcConnectionCache connectionCache; private final Logger logger = LoggerFactory.getLogger(getClass()); private final Integer DEFAULT_TIMEOUT = 1000000; private final Integer DEFAULT_RETRY_BACKOFF = 5000; @@ -76,7 +76,9 @@ protected void onStartup() { @Override protected void onShutdown() { - //Do Nothing + // The cache holds the PLC connections this backend read and wrote through, so shutting + // the backend down has to release them. + closeConnectionCache(); } public PlcDriverManager getDriverManager() { @@ -84,8 +86,24 @@ public PlcDriverManager getDriverManager() { } public void setDriverManager(PlcDriverManager driverManager) { + // A cache built for the previous driver manager is of no use any more, and nobody else + // can close it. + closeConnectionCache(); this.driverManager = driverManager; - this.cachedPlcConnectionManager = CachedPlcConnectionManager.getBuilder(driverManager.getConnectionManager()).build(); + this.connectionCache = PlcConnectionCache.getBuilder() + .withConnectionFactory(driverManager.getConnectionFactory()) + .build(); + } + + /** + * The closed cache is kept rather than cleared: closing it again does nothing, and a read or + * write arriving after the shutdown then fails with the PlcConnectionException those paths + * already handle, instead of a NullPointerException. + */ + private void closeConnectionCache() { + if (connectionCache != null) { + connectionCache.close(); + } } public PlcTag getTag(String tag, String connectionString) throws PlcConnectionException { @@ -156,7 +174,7 @@ public DataValue getValue(AttributeFilterContext.GetAttributeContext ctx, String //Try to connect to PLC try { - connection = cachedPlcConnectionManager.getConnection(connectionString); + connection = connectionCache.getConnection(connectionString); logger.debug(connectionString + " Connected"); } catch (PlcConnectionException e) { logger.error("Failed to connect to device, error raised - " + e); @@ -248,7 +266,7 @@ public DataValue getValue(AttributeFilterContext.GetAttributeContext ctx, String } public void setValue(String tag, String value, String connectionString) { - try (PlcConnection connection = cachedPlcConnectionManager.getConnection(connectionString)) { + try (PlcConnection connection = connectionCache.getConnection(connectionString)) { if (!connection.getMetadata().isWriteSupported()) { logger.error("This connection doesn't support writing."); return; diff --git a/plc4j/tools/opcua-server/src/test/java/org/apache/plc4x/java/opcuaserver/backend/Plc4xCommunicationLifecycleTest.java b/plc4j/tools/opcua-server/src/test/java/org/apache/plc4x/java/opcuaserver/backend/Plc4xCommunicationLifecycleTest.java new file mode 100644 index 000000000..9ab80201c --- /dev/null +++ b/plc4j/tools/opcua-server/src/test/java/org/apache/plc4x/java/opcuaserver/backend/Plc4xCommunicationLifecycleTest.java @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.java.opcuaserver.backend; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import org.apache.plc4x.java.api.PlcDriverManager; +import org.junit.jupiter.api.Test; + +/** + * The backend builds a PlcConnectionCache, which holds on to the PLC connections it hands + * out - so the backend's lifecycle has to release them. + */ +public class Plc4xCommunicationLifecycleTest { + + @Test + public void shutdownReleasesTheConnectionCache() { + Plc4xCommunication communication = new Plc4xCommunication(); + communication.startup(); + + assertDoesNotThrow(communication::shutdown); + // Closing an already closed cache does nothing, so a second shutdown must stay harmless. + assertDoesNotThrow(communication::shutdown); + } + + /** + * Replacing the driver manager replaces the cache built from it. Nobody else holds the old + * one, so it has to be closed here or it is leaked. + */ + @Test + public void replacingTheDriverManagerReleasesThePreviousCache() { + Plc4xCommunication communication = new Plc4xCommunication(); + // Starting up builds the first cache; setting a driver manager replaces it. + communication.startup(); + + assertDoesNotThrow(() -> communication.setDriverManager(PlcDriverManager.getDefault())); + + // The freshly built cache is still usable: writing through it must not fail on a closed + // manager. The simulated driver accepts the write without needing a real device. + assertDoesNotThrow(() -> communication.setValue("STATE/foo:DINT", "42", "simulated://localhost")); + + communication.shutdown(); + } + +} diff --git a/plc4j/tools/plc4x-server/README.md b/plc4j/tools/plc4x-server/README.md new file mode 100644 index 000000000..dfd70617d --- /dev/null +++ b/plc4j/tools/plc4x-server/README.md @@ -0,0 +1,186 @@ + + +# PLC4X Server + +A small relay server that speaks the **PLC4X proxy protocol**. A client using the +`plc4x` driver connects to this server, and the server opens the *real* connection to a +PLC on its behalf and forwards reads and writes. This is useful when the PLCs are only +reachable from the machine running the server (e.g. behind a firewall or on an isolated +OT network), while clients live elsewhere. + +``` + ┌────────────┐ plc4x proxy protocol ┌──────────────┐ native protocol ┌───────┐ + │ client │ ────────────────────────â–ļ │ PLC4X Server │ ──────────────────â–ļ │ PLC │ + │ (plc4x drv)│ TLS + auth │ │ (s7, modbus, â€Ļ) │ │ + └────────────┘ └──────────────┘ └───────┘ +``` + +The server bundles **all** PLC4X drivers, so the relayed connection string can target any +supported protocol (`s7`, `modbus`, `ads`, `opcua`, `simulated`, â€Ļ). + +## Security model + +- **Authentication is mandatory.** Every client connection must complete a + username/password handshake before any operation is accepted. There is no + unauthenticated mode. +- **TLS is the default transport.** Credentials and payloads are encrypted in transit. + Plaintext TCP is available only as an explicit opt-in for trusted networks/testing. + +## Building + +From the module directory: + +```bash +mvn package +``` + +This produces a runnable fat-jar at: + +``` +target/plc4j-plc4x-server-.jar +``` + +## Running the server + +```bash +java -jar target/plc4j-plc4x-server-.jar [port] +``` + +The listen port may be given as the first argument, or via the +`plc4x.server.port` system property / `PLC4X_SERVER_PORT` environment variable. +If omitted it defaults to **59837**. + +### First start with no configuration + +If you start the server without configuring credentials, it generates a default identity +and prints it **once** to the console: + +``` +============================================================ + No PLC4X server credentials configured - generated defaults: + username: toddy + password: 3Qm0v2yq... (randomly generated) + Provide plc4x.server.username/password to set your own. +============================================================ +``` + +Because no keystore is configured either, it also generates an ephemeral self-signed +TLS certificate and logs its fingerprint so a client can trust/pin it: + +``` +No keystore configured - generated an ephemeral self-signed certificate. +Server certificate SHA-256 fingerprint: B1:A5:51:F8:...:35:CE +``` + +> Configured credentials are **never** logged — only generated ones are printed. + +## Configuration + +All settings are read from system properties (`-Dâ€Ļ`) or environment variables. + +| Setting | System property | Environment variable | Default | +|----------------|----------------------------------|-------------------------------|--------------------| +| Listen port | `plc4x.server.port` | `PLC4X_SERVER_PORT` | `59837` | +| Username | `plc4x.server.username` | `PLC4X_SERVER_USERNAME` | `toddy` | +| Password | `plc4x.server.password` | `PLC4X_SERVER_PASSWORD` | *(generated)* | +| Plaintext mode | `plc4x.server.plaintext` | `PLC4X_SERVER_PLAINTEXT` | `false` (TLS on) | +| TLS keystore | `plc4x.server.keystore` | — | *(self-signed)* | +| Keystore pass. | `plc4x.server.keystore-password` | — | — | + +### Example: explicit credentials + your own TLS certificate + +```bash +java \ + -Dplc4x.server.port=59837 \ + -Dplc4x.server.username=operator \ + -Dplc4x.server.password='s3cr3t!' \ + -Dplc4x.server.keystore=/etc/plc4x/server.p12 \ + -Dplc4x.server.keystore-password='keystorepass' \ + -jar target/plc4j-plc4x-server-.jar +``` + +The keystore must be a PKCS12 (or JKS) file containing the server's private key and +certificate. + +### Example: plaintext (trusted network / local testing only) + +```bash +java -Dplc4x.server.plaintext=true \ + -Dplc4x.server.username=operator \ + -Dplc4x.server.password='s3cr3t!' \ + -jar target/plc4j-plc4x-server-.jar +``` + +## Connecting a client + +A client uses the `plc4x` driver. The connection string points at the server, and the +**`remote-connection-string`** parameter (URL-encoded) tells the server which PLC to open. + +### Connection string format + +``` +plc4x:://:?remote-connection-string=&username=&password=[&tls.verify=false] +``` + +- `` is `tls` (default) or `tcp` (plaintext). `plc4x://â€Ļ` without a prefix + uses the default, TLS. +- `remote-connection-string` is the **URL-encoded** connection string the server should + open to the actual PLC. +- `username` / `password` are mandatory. +- `tls.verify=false` disables certificate validation — needed when the server uses an + auto-generated self-signed certificate. With a properly trusted (CA-signed) certificate, + leave it at its default (`true`). + +### Example (Java) + +Target PLC URL `s7://10.10.1.5` → URL-encoded as `s7%3A%2F%2F10.10.1.5`. + +```java +String url = "plc4x:tls://server.example.com:59837" + + "?remote-connection-string=s7%3A%2F%2F10.10.1.5" + + "&username=operator" + + "&password=s3cr3t!" + + "&tls.verify=false"; // self-signed server cert + +try (PlcConnection connection = new DefaultPlcDriverManager().getConnection(url)) { + PlcReadRequest request = connection.readRequestBuilder() + .addTagAddress("value", "%DB1.DBW0:INT") + .build(); + PlcReadResponse response = request.execute().get(); + System.out.println(response.getInteger("value")); +} +``` + +### Plaintext client + +```java +String url = "plc4x:tcp://server.example.com:59837" + + "?remote-connection-string=s7%3A%2F%2F10.10.1.5" + + "&username=operator&password=s3cr3t!"; +``` + +## Troubleshooting + +| Symptom | Cause / fix | +|----------------------------------------------------------------------|-----------------------------------------------------------------------------------------------| +| `Server certificate not trusted â€Ļ PKIX path building failed` | TLS cert not trusted by the client. Add `tls.verify=false`, or trust/pin the server cert. | +| Connect fails with `ACCESS_DENIED` / authentication error | Missing or wrong `username`/`password`. | +| Connect hangs or fails immediately on a `tcp` client vs `tls` server | Transport mismatch — client and server must agree on TLS vs plaintext. | +| `INVALID_ADDRESS` on connect | The server could not open the `remote-connection-string` (bad URL, PLC unreachable). | diff --git a/plc4j/tools/plc4x-server/pom.xml b/plc4j/tools/plc4x-server/pom.xml index 40036d0a4..da26649d3 100644 --- a/plc4j/tools/plc4x-server/pom.xml +++ b/plc4j/tools/plc4x-server/pom.xml @@ -17,14 +17,12 @@ specific language governing permissions and limitations under the License. --> - - - 4.0.0 + - org.apache.plc4x + org.apache.plc4x.extras plc4j-tools - 0.13.0-SNAPSHOT + 1.0.0-SNAPSHOT plc4j-plc4x-server @@ -33,7 +31,9 @@ Server that can be used by drivers of the 'plc4x' protocol to relay requests which are then executed on this machine. - 2024-02-16T14:53:02Z + 2026-09-20T15:22:10Z + true + true @@ -51,6 +51,18 @@ false + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + org.apache.plc4x.java.tools.plc4xserver.Plc4xServer @@ -91,74 +103,79 @@ org.apache.plc4x plc4j-api - 0.13.0-SNAPSHOT - - - org.apache.plc4x - plc4j-spi - 0.13.0-SNAPSHOT + ${plc4x.version} org.apache.plc4x - plc4j-connection-cache - 0.13.0-SNAPSHOT + plc4j-tools-connection-cache + ${plc4x.version} org.apache.plc4x plc4j-driver-plc4x - 0.13.0-SNAPSHOT + ${plc4x.version} org.apache.plc4x plc4j-driver-all - 0.13.0-SNAPSHOT + ${plc4x.version} pom runtime + + + org.apache.plc4x + plc4j-transports-api + ${plc4x.version} + + + - io.netty - netty-buffer - 4.1.112.Final + org.bouncycastle + bcprov-jdk18on + 1.84 - io.netty - netty-transport - 4.1.112.Final + org.bouncycastle + bcpkix-jdk18on + 1.84 org.slf4j slf4j-api - 2.0.13 + 2.0.18 org.slf4j log4j-over-slf4j - 2.0.13 + 2.0.18 ch.qos.logback logback-classic - 1.5.6 + 1.5.38 compile ch.qos.logback logback-core - 1.5.6 + 1.5.38 runtime org.junit.jupiter junit-jupiter-api - 5.10.3 + 6.1.1 test diff --git a/plc4j/tools/plc4x-server/src/main/java/org/apache/plc4x/java/tools/plc4xserver/Plc4xServer.java b/plc4j/tools/plc4x-server/src/main/java/org/apache/plc4x/java/tools/plc4xserver/Plc4xServer.java index e1dadd43c..d186dd29a 100644 --- a/plc4j/tools/plc4x-server/src/main/java/org/apache/plc4x/java/tools/plc4xserver/Plc4xServer.java +++ b/plc4j/tools/plc4x-server/src/main/java/org/apache/plc4x/java/tools/plc4xserver/Plc4xServer.java @@ -21,54 +21,88 @@ import static java.lang.Runtime.getRuntime; -import io.netty.bootstrap.ServerBootstrap; -import io.netty.buffer.ByteBuf; -import io.netty.channel.ChannelFuture; -import io.netty.channel.ChannelInitializer; -import io.netty.channel.ChannelOption; -import io.netty.channel.ChannelPipeline; -import io.netty.channel.EventLoopGroup; -import io.netty.channel.nio.NioEventLoopGroup; -import io.netty.channel.socket.SocketChannel; -import io.netty.channel.socket.nio.NioServerSocketChannel; import java.io.IOException; import java.net.ServerSocket; -import java.util.Arrays; +import java.net.Socket; +import java.security.SecureRandom; +import java.util.Base64; import java.util.Optional; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.Future; -import java.util.function.ToIntFunction; +import javax.net.ssl.SSLServerSocket; +import javax.net.ssl.SSLServerSocketFactory; +import org.apache.plc4x.java.DefaultPlcDriverManager; +import org.apache.plc4x.java.api.PlcConnectionManager; +import org.apache.plc4x.java.api.exceptions.PlcConnectionException; import org.apache.plc4x.java.api.exceptions.PlcRuntimeException; -import org.apache.plc4x.java.plc4x.readwrite.Plc4xConstants; -import org.apache.plc4x.java.plc4x.readwrite.Plc4xMessage; -import org.apache.plc4x.java.spi.connection.GeneratedProtocolMessageCodec; -import org.apache.plc4x.java.spi.generation.ByteOrder; +import org.apache.plc4x.java.plc4x.Plc4xMessageCodec; +import org.apache.plc4x.java.plc4x.readwrite.Constants; +import org.apache.plc4x.java.spi.drivers.exceptions.MessageCodecException; import org.apache.plc4x.java.tools.plc4xserver.protocol.Plc4xServerAdapter; +import org.apache.plc4x.java.tools.plc4xserver.protocol.SocketTransportInstance; +import org.apache.plc4x.java.utils.cache.PlcConnectionCache; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** + * TCP/TLS server that relays {@code plc4x} proxy requests to PLCs reachable from this machine. + * + *

Security model:

+ *
    + *
  • Authentication is mandatory. Every connection must present a username and + * password before any operation. If none are configured the server uses the default user + * {@code toddy} with a freshly generated secure password that is printed to the console once + * at startup. Explicitly configured credentials are never logged.
  • + *
  • TLS is the default transport. Without a configured keystore the server generates + * an ephemeral self-signed certificate and prints its SHA-256 fingerprint so clients can pin + * it. Plaintext TCP is available as an explicit opt-in for trusted networks/testing.
  • + *
+ */ public class Plc4xServer { public static final String SERVER_PORT_PROPERTY = "plc4x.server.port"; public static final String SERVER_PORT_ENVIRONMENT_VARIABLE = "PLC4X_SERVER_PORT"; - public static int DEFAULT_PORT = Plc4xConstants.PLC4XTCPDEFAULTPORT; + public static final String SERVER_USERNAME_PROPERTY = "plc4x.server.username"; + public static final String SERVER_USERNAME_ENVIRONMENT_VARIABLE = "PLC4X_SERVER_USERNAME"; + public static final String SERVER_PASSWORD_PROPERTY = "plc4x.server.password"; + public static final String SERVER_PASSWORD_ENVIRONMENT_VARIABLE = "PLC4X_SERVER_PASSWORD"; + public static final String SERVER_PLAINTEXT_PROPERTY = "plc4x.server.plaintext"; + public static final String SERVER_PLAINTEXT_ENVIRONMENT_VARIABLE = "PLC4X_SERVER_PLAINTEXT"; + public static final String SERVER_KEYSTORE_PROPERTY = "plc4x.server.keystore"; + public static final String SERVER_KEYSTORE_PASSWORD_PROPERTY = "plc4x.server.keystore-password"; + + public static final String DEFAULT_USERNAME = "toddy"; + public static int DEFAULT_PORT = Constants.PLC4XTCPDEFAULTPORT; - private static final Logger LOG = LoggerFactory.getLogger(Plc4xServerAdapter.class); + private static final Logger LOG = LoggerFactory.getLogger(Plc4xServer.class); - private EventLoopGroup loopGroup; - private EventLoopGroup workerGroup; - private ChannelFuture channelFuture; private Integer port; + private String username; + private String password; + private boolean plaintext = false; + private String keystorePath; + private String keystorePassword; + + // Created when the server starts and closed when it stops, just like the socket and the + // executor: the cache holds the PLC connections handed to the client sessions, so it is the + // server's job to release them. + private PlcConnectionManager connectionManager; + private ServerSocket serverSocket; + private Thread acceptThread; + private ExecutorService connectionExecutor; + private volatile boolean running = false; public static void main(String[] args) throws Exception { final Plc4xServer server = new Plc4xServer(); Future serverFuture = server.start( - Arrays.stream(args).findFirst() // port number given as first command line argument - .or(() -> Optional.ofNullable(System.getProperty(SERVER_PORT_PROPERTY))) - .or(() -> Optional.ofNullable(System.getenv(SERVER_PORT_ENVIRONMENT_VARIABLE))) - .map(Integer::parseInt) - .orElse(DEFAULT_PORT) + Arrays_findFirst(args) // port number given as first command line argument + .or(() -> Optional.ofNullable(System.getProperty(SERVER_PORT_PROPERTY))) + .or(() -> Optional.ofNullable(System.getenv(SERVER_PORT_ENVIRONMENT_VARIABLE))) + .map(Integer::parseInt) + .orElse(DEFAULT_PORT) ); CompletableFuture serverRunning = new CompletableFuture<>(); getRuntime().addShutdownHook(new Thread(() -> serverRunning.complete(null))); @@ -86,90 +120,208 @@ public static void main(String[] args) throws Exception { } } + private static Optional Arrays_findFirst(String[] args) { + return args.length > 0 ? Optional.of(args[0]) : Optional.empty(); + } + public Integer getPort() { return port; } + /** + * The effective username clients must authenticate with. + */ + public String getUsername() { + return username; + } + + /** + * The effective password clients must authenticate with (generated if none was configured). + */ + public String getPassword() { + return password; + } + + public void setUsername(String username) { + this.username = username; + } + + public void setPassword(String password) { + this.password = password; + } + + public void setPlaintext(boolean plaintext) { + this.plaintext = plaintext; + } + + public void setKeystore(String keystorePath, String keystorePassword) { + this.keystorePath = keystorePath; + this.keystorePassword = keystorePassword; + } + public Future start() { return start(0); } public Future start(int port) { - if (port == 0) { - this.port = findRandomFreePort(); - } else { - this.port = port; - } - - if (loopGroup != null) { + if (running) { return CompletableFuture.completedFuture(null); } + this.port = (port == 0) ? findRandomFreePort() : port; - loopGroup = new NioEventLoopGroup(); - workerGroup = new NioEventLoopGroup(); + resolveCredentials(); + resolvePlaintext(); + + try { + serverSocket = plaintext ? new ServerSocket(this.port) : createTlsServerSocket(this.port); + } catch (Exception e) { + return CompletableFuture.failedFuture( + new PlcRuntimeException("Failed to start PLC4X server", e)); + } - channelFuture = new ServerBootstrap() - .group(loopGroup, workerGroup) - .channel(NioServerSocketChannel.class) - .childHandler(new SocketChannelChannelInitializer()) - .option(ChannelOption.SO_BACKLOG, 128) - .childOption(ChannelOption.SO_KEEPALIVE, true) - .bind(this.port); + running = true; + connectionManager = PlcConnectionCache.getBuilder() + .withConnectionFactory(new DefaultPlcDriverManager()) + .build(); + connectionExecutor = Executors.newVirtualThreadPerTaskExecutor(); + acceptThread = new Thread(this::acceptLoop, "Plc4xServer-Accept"); + acceptThread.setDaemon(true); + acceptThread.start(); - return channelFuture; + LOG.info("PLC4X server listening on port {} ({})", this.port, plaintext ? "plaintext TCP" : "TLS"); + return CompletableFuture.completedFuture(null); } public void stop() { - if (workerGroup == null) { - return; + running = false; + if (serverSocket != null) { + try { + serverSocket.close(); + } catch (IOException e) { + LOG.debug("Error closing server socket", e); + } + serverSocket = null; } + if (connectionExecutor != null) { + connectionExecutor.shutdownNow(); + connectionExecutor = null; + } + // Closed last, once no session can ask it for a connection any more. + if (connectionManager != null) { + try { + connectionManager.close(); + } catch (PlcConnectionException e) { + LOG.debug("Error closing the connection manager", e); + } + connectionManager = null; + } + } - channelFuture.cancel(true); - - workerGroup.shutdownGracefully(); - loopGroup.shutdownGracefully(); - - workerGroup = null; - loopGroup = null; + private void acceptLoop() { + while (running) { + final Socket socket; + try { + socket = serverSocket.accept(); + } catch (IOException e) { + if (running) { + LOG.debug("Accept failed", e); + } + return; + } + connectionExecutor.submit(() -> handleConnection(socket)); + } } - private static class SocketChannelChannelInitializer extends ChannelInitializer { + private void handleConnection(Socket socket) { + try (socket) { + SocketTransportInstance transport = new SocketTransportInstance(socket); + // The codec needs a message handler and the adapter needs the codec to send replies, + // so wire them through a one-slot holder to break the construction cycle. + final Plc4xServerAdapter[] holder = new Plc4xServerAdapter[1]; + Plc4xMessageCodec codec = new Plc4xMessageCodec(transport, msg -> holder[0].handle(msg)); + holder[0] = new Plc4xServerAdapter(connectionManager, codec, username, password); - @Override - public void initChannel(SocketChannel channel) { - ChannelPipeline pipeline = channel.pipeline(); - pipeline.addLast( - new GeneratedProtocolMessageCodec<>( - Plc4xMessage.class, - Plc4xMessage::staticParse, - ByteOrder.BIG_ENDIAN, - null, - new ByteLengthEstimator(), - null - ) - ); - pipeline.addLast(new Plc4xServerAdapter()); + while (running && transport.fill()) { + codec.processIncomingData(); + } + } catch (MessageCodecException e) { + LOG.debug("Protocol error - dropping connection", e); + } catch (IOException e) { + LOG.debug("Connection I/O error", e); } } - private static class ByteLengthEstimator implements ToIntFunction { + private SSLServerSocket createTlsServerSocket(int port) throws Exception { + ServerTlsContext tlsContext; + String keystore = keystorePath != null ? keystorePath : System.getProperty(SERVER_KEYSTORE_PROPERTY); + if (keystore != null) { + String pwd = keystorePassword != null + ? keystorePassword : System.getProperty(SERVER_KEYSTORE_PASSWORD_PROPERTY); + tlsContext = ServerTlsContext.fromKeystore(keystore, pwd, null); + } else { + tlsContext = ServerTlsContext.selfSigned(); + LOG.info("No keystore configured - generated an ephemeral self-signed certificate."); + LOG.info("Server certificate SHA-256 fingerprint: {}", tlsContext.getCertificateFingerprint()); + } + SSLServerSocketFactory factory = tlsContext.getSslContext().getServerSocketFactory(); + return (SSLServerSocket) factory.createServerSocket(port); + } - @Override - public int applyAsInt(ByteBuf byteBuf) { - if (byteBuf.readableBytes() >= 3) { - return byteBuf.getUnsignedShort(byteBuf.readerIndex() + 1); + /** + * Resolves the effective credentials from explicit config, system properties or environment, + * falling back to the default user with a generated password. Generated passwords are printed + * once; configured passwords are never logged. + */ + private void resolveCredentials() { + if (username == null) { + username = Optional.ofNullable(System.getProperty(SERVER_USERNAME_PROPERTY)) + .or(() -> Optional.ofNullable(System.getenv(SERVER_USERNAME_ENVIRONMENT_VARIABLE))) + .orElse(DEFAULT_USERNAME); + } + boolean generated = false; + if (password == null) { + String configured = Optional.ofNullable(System.getProperty(SERVER_PASSWORD_PROPERTY)) + .or(() -> Optional.ofNullable(System.getenv(SERVER_PASSWORD_ENVIRONMENT_VARIABLE))) + .orElse(null); + if (configured != null) { + password = configured; + } else { + password = generateSecurePassword(); + generated = true; } - return -1; + } + if (generated) { + // Intentionally printed to stdout (not just the log) so it is visible on first start. + System.out.println("============================================================"); + System.out.println(" No PLC4X server credentials configured - generated defaults:"); + System.out.println(" username: " + username); + System.out.println(" password: " + password); + System.out.println(" Provide plc4x.server.username/password to set your own."); + System.out.println("============================================================"); + } else { + LOG.info("Using configured credentials for user '{}'", username); } } + private void resolvePlaintext() { + if (!plaintext) { + plaintext = Boolean.parseBoolean(System.getProperty(SERVER_PLAINTEXT_PROPERTY)) + || Boolean.parseBoolean(System.getenv(SERVER_PLAINTEXT_ENVIRONMENT_VARIABLE) == null + ? "false" : System.getenv(SERVER_PLAINTEXT_ENVIRONMENT_VARIABLE)); + } + } + + private static String generateSecurePassword() { + byte[] bytes = new byte[24]; + new SecureRandom().nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + private static int findRandomFreePort() { - final int port; try (ServerSocket socket = new ServerSocket(0)) { - port = socket.getLocalPort(); + return socket.getLocalPort(); } catch (IOException e) { - throw new RuntimeException("Couldn't find any free port.", e); + throw new PlcRuntimeException("Couldn't find any free port.", e); } - return port; } } diff --git a/plc4j/tools/plc4x-server/src/main/java/org/apache/plc4x/java/tools/plc4xserver/ServerTlsContext.java b/plc4j/tools/plc4x-server/src/main/java/org/apache/plc4x/java/tools/plc4xserver/ServerTlsContext.java new file mode 100644 index 000000000..3a0b8a2d2 --- /dev/null +++ b/plc4j/tools/plc4x-server/src/main/java/org/apache/plc4x/java/tools/plc4xserver/ServerTlsContext.java @@ -0,0 +1,153 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.java.tools.plc4xserver; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.HexFormat; + +/** + * Holds the {@link SSLContext} the server listens with, plus the SHA-256 fingerprint of the + * presented certificate. Either loads an operator-provided keystore or, when none is configured, + * generates an ephemeral self-signed certificate so TLS works with zero configuration. The + * fingerprint lets a client pin/trust the auto-generated identity. + */ +public final class ServerTlsContext { + + private final SSLContext sslContext; + private final String certificateFingerprint; + private final boolean selfSigned; + + private ServerTlsContext(SSLContext sslContext, String certificateFingerprint, boolean selfSigned) { + this.sslContext = sslContext; + this.certificateFingerprint = certificateFingerprint; + this.selfSigned = selfSigned; + } + + public SSLContext getSslContext() { + return sslContext; + } + + public String getCertificateFingerprint() { + return certificateFingerprint; + } + + public boolean isSelfSigned() { + return selfSigned; + } + + /** + * Builds a TLS context from an existing PKCS12/JKS keystore. + */ + public static ServerTlsContext fromKeystore(String keystorePath, String keystorePassword, + String keystoreType) throws Exception { + char[] password = keystorePassword == null ? new char[0] : keystorePassword.toCharArray(); + KeyStore keyStore = KeyStore.getInstance(keystoreType == null ? "PKCS12" : keystoreType); + try (var in = new java.io.FileInputStream(keystorePath)) { + keyStore.load(in, password); + } + SSLContext sslContext = buildContext(keyStore, password); + return new ServerTlsContext(sslContext, fingerprintOfFirst(keyStore), false); + } + + /** + * Generates an ephemeral self-signed certificate and wraps it in a fresh TLS context. + */ + public static ServerTlsContext selfSigned() throws Exception { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); + keyPairGenerator.initialize(2048, new SecureRandom()); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + + X500Name subject = new X500Name("CN=PLC4X-Server"); + Instant now = Instant.now(); + Date notBefore = Date.from(now.minus(1, ChronoUnit.HOURS)); + Date notAfter = Date.from(now.plus(3650, ChronoUnit.DAYS)); + BigInteger serial = BigInteger.valueOf(System.nanoTime()); + + X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder( + subject, serial, notBefore, notAfter, subject, keyPair.getPublic()); + // Add localhost / loopback SANs so common-name verification can succeed for local use. + GeneralNames sans = new GeneralNames(new GeneralName[]{ + new GeneralName(GeneralName.dNSName, "localhost"), + new GeneralName(GeneralName.iPAddress, "127.0.0.1") + }); + certBuilder.addExtension(Extension.subjectAlternativeName, false, sans); + + ContentSigner signer = new JcaContentSignerBuilder("SHA256withRSA").build(keyPair.getPrivate()); + X509Certificate certificate = new JcaX509CertificateConverter() + .getCertificate(certBuilder.build(signer)); + + char[] password = new char[0]; + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load(null, password); + keyStore.setKeyEntry("plc4x-server", keyPair.getPrivate(), password, + new X509Certificate[]{certificate}); + + SSLContext sslContext = buildContext(keyStore, password); + return new ServerTlsContext(sslContext, fingerprint(certificate), true); + } + + private static SSLContext buildContext(KeyStore keyStore, char[] password) throws Exception { + KeyManagerFactory keyManagerFactory = + KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + keyManagerFactory.init(keyStore, password); + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(keyManagerFactory.getKeyManagers(), null, new SecureRandom()); + return sslContext; + } + + private static String fingerprintOfFirst(KeyStore keyStore) throws Exception { + var aliases = keyStore.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + var cert = keyStore.getCertificate(alias); + if (cert instanceof X509Certificate x509) { + return fingerprint(x509); + } + } + return "unknown"; + } + + private static String fingerprint(X509Certificate certificate) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(certificate.getEncoded()); + return HexFormat.ofDelimiter(":").withUpperCase().formatHex(hash); + } + +} diff --git a/plc4j/tools/plc4x-server/src/main/java/org/apache/plc4x/java/tools/plc4xserver/protocol/Plc4xServerAdapter.java b/plc4j/tools/plc4x-server/src/main/java/org/apache/plc4x/java/tools/plc4xserver/protocol/Plc4xServerAdapter.java index 7194b24ff..e533e4844 100644 --- a/plc4j/tools/plc4x-server/src/main/java/org/apache/plc4x/java/tools/plc4xserver/protocol/Plc4xServerAdapter.java +++ b/plc4j/tools/plc4x-server/src/main/java/org/apache/plc4x/java/tools/plc4xserver/protocol/Plc4xServerAdapter.java @@ -18,159 +18,217 @@ */ package org.apache.plc4x.java.tools.plc4xserver.protocol; -import io.netty.channel.ChannelHandlerContext; -import io.netty.channel.ChannelInboundHandlerAdapter; import org.apache.plc4x.java.api.PlcConnection; -import org.apache.plc4x.java.api.PlcConnectionManager; +import org.apache.plc4x.java.api.PlcConnectionFactory; import org.apache.plc4x.java.api.messages.PlcReadRequest; import org.apache.plc4x.java.api.messages.PlcReadResponse; import org.apache.plc4x.java.api.messages.PlcWriteRequest; import org.apache.plc4x.java.api.messages.PlcWriteResponse; import org.apache.plc4x.java.api.types.PlcResponseCode; import org.apache.plc4x.java.api.value.PlcValue; +import org.apache.plc4x.java.plc4x.Plc4xMessageCodec; import org.apache.plc4x.java.plc4x.readwrite.*; -import org.apache.plc4x.java.utils.cache.CachedPlcConnectionManager; +import org.apache.plc4x.java.spi.drivers.exceptions.MessageCodecException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; -public class Plc4xServerAdapter extends ChannelInboundHandlerAdapter { +/** + * Per-connection handler for the PLC4X proxy protocol. One instance lives for the lifetime of a + * single accepted socket; the {@link SocketTransportInstance} read loop feeds the codec, which + * invokes {@link #handle(Plc4xMessage)} for every decoded message. + * + *

Authentication is mandatory: a connection must complete a successful {@code AUTH_REQUEST} + * before any {@code CONNECT}/{@code READ}/{@code WRITE} is honoured. Anything else is answered + * with {@code ACCESS_DENIED}.

+ */ +public class Plc4xServerAdapter implements Consumer { private final Logger logger = LoggerFactory.getLogger(Plc4xServerAdapter.class); - private final PlcConnectionManager connectionManager; - private final AtomicInteger connectionIdGenerator; - private final ConcurrentHashMap connectionUrls; + private final PlcConnectionFactory connectionFactory; + private final Plc4xMessageCodec codec; + private final byte[] expectedUsername; + private final byte[] expectedPassword; + + private final AtomicInteger connectionIdGenerator = new AtomicInteger(1); + private final ConcurrentHashMap connectionUrls = new ConcurrentHashMap<>(); - public Plc4xServerAdapter() { - connectionManager = CachedPlcConnectionManager.getBuilder().build(); - connectionIdGenerator = new AtomicInteger(1); - connectionUrls = new ConcurrentHashMap<>(); + private volatile boolean authenticated = false; + + public Plc4xServerAdapter(PlcConnectionFactory connectionFactory, Plc4xMessageCodec codec, + String expectedUsername, String expectedPassword) { + this.connectionFactory = connectionFactory; + this.codec = codec; + this.expectedUsername = expectedUsername.getBytes(StandardCharsets.UTF_8); + this.expectedPassword = expectedPassword.getBytes(StandardCharsets.UTF_8); } @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) { - if (msg instanceof Plc4xMessage) { - final Plc4xMessage plc4xMessage = (Plc4xMessage) msg; - switch (plc4xMessage.getRequestType()) { - case CONNECT_REQUEST: { - Plc4xConnectRequest request = (Plc4xConnectRequest) plc4xMessage; - try (final PlcConnection ignored = connectionManager.getConnection(request.getConnectionString())) { - //connection.ping().get(); - final int connectionId = connectionIdGenerator.getAndIncrement(); - connectionUrls.put(connectionId, request.getConnectionString()); - Plc4xConnectResponse response = new Plc4xConnectResponse( - request.getRequestId(), connectionId, Plc4xResponseCode.OK); - ctx.writeAndFlush(response); - } catch (Exception e) { - Plc4xConnectResponse response = new Plc4xConnectResponse( - request.getRequestId(), 0, Plc4xResponseCode.INVALID_ADDRESS); - ctx.writeAndFlush(response); - } - break; + public void accept(Plc4xMessage message) { + handle(message); + } + + public void handle(Plc4xMessage plc4xMessage) { + switch (plc4xMessage.getRequestType()) { + case AUTH_REQUEST: + handleAuth((Plc4xAuthRequest) plc4xMessage); + break; + case CONNECT_REQUEST: + if (requireAuth(plc4xMessage)) { + handleConnect((Plc4xConnectRequest) plc4xMessage); + } + break; + case READ_REQUEST: + if (requireAuth(plc4xMessage)) { + handleRead((Plc4xReadRequest) plc4xMessage); } - case READ_REQUEST: { - final Plc4xReadRequest request = (Plc4xReadRequest) plc4xMessage; - String connectionUrl = connectionUrls.get(request.getConnectionId()); - try (final PlcConnection connection = connectionManager.getConnection(connectionUrl)) { - // Build a read request for all tags in the request. - final PlcReadRequest.Builder builder = connection.readRequestBuilder(); - for (Plc4xTagRequest requestTag : request.getTags()) { - builder.addTagAddress(requestTag.getTag().getName(), requestTag.getTag().getTagQuery()); - } - final PlcReadRequest rr = builder.build(); - - // Execute the query. - // (It has to be synchronously when working with the connection cache) - final PlcReadResponse apiReadResponse = rr.execute().get(); - - // Create the response. - List tags = new ArrayList<>(apiReadResponse.getTagNames().size()); - for (Plc4xTagRequest plc4xRequestTag : request.getTags()) { - final PlcResponseCode responseCode = apiReadResponse.getResponseCode(plc4xRequestTag.getTag().getName()); - Plc4xResponseCode resCode; - Plc4xValueType valueType; - PlcValue value; - if(responseCode == PlcResponseCode.OK) { - resCode = Plc4xResponseCode.OK; - value = apiReadResponse.getPlcValue(plc4xRequestTag.getTag().getName()); - final String valueTypeName = value.getClass().getSimpleName(); - // Cut off the "Plc" prefix to get the name of the PlcValueType. - valueType = Plc4xValueType.valueOf(valueTypeName.substring(3)); - } else { - resCode = Plc4xResponseCode.INVALID_ADDRESS; - value = null; - valueType = Plc4xValueType.NULL; - } - tags.add(new Plc4xTagValueResponse( - plc4xRequestTag.getTag(), resCode, valueType, value)); - } - Plc4xReadResponse response = new Plc4xReadResponse( - request.getRequestId(), request.getConnectionId(), Plc4xResponseCode.OK, tags); - - // Send the response. - ctx.writeAndFlush(response); - } catch (Exception e) { - logger.error("Error executing request", e); - Plc4xReadResponse response = new Plc4xReadResponse( - request.getRequestId(), request.getConnectionId(), - Plc4xResponseCode.INVALID_ADDRESS, Collections.emptyList()); - ctx.writeAndFlush(response); - } - break; + break; + case WRITE_REQUEST: + if (requireAuth(plc4xMessage)) { + handleWrite((Plc4xWriteRequest) plc4xMessage); + } + break; + default: + logger.debug("Ignoring unsupported request type {}", plc4xMessage.getRequestType()); + } + } + + private void handleAuth(Plc4xAuthRequest request) { + boolean ok = constantTimeEquals(expectedUsername, request.getUsername().getBytes(StandardCharsets.UTF_8)) + & constantTimeEquals(expectedPassword, request.getPassword().getBytes(StandardCharsets.UTF_8)); + authenticated = ok; + // Never log the supplied credentials, just the outcome. + logger.info("Authentication {}", ok ? "succeeded" : "failed"); + send(new Plc4xAuthResponse(request.getRequestId(), + ok ? Plc4xResponseCode.OK : Plc4xResponseCode.ACCESS_DENIED)); + } + + /** + * Returns {@code true} if the connection is authenticated. Otherwise emits a type-appropriate + * {@code ACCESS_DENIED} response and returns {@code false}. + */ + private boolean requireAuth(Plc4xMessage message) { + if (authenticated) { + return true; + } + logger.warn("Rejecting {} - connection is not authenticated", message.getRequestType()); + switch (message.getRequestType()) { + case CONNECT_REQUEST -> send(new Plc4xConnectResponse( + message.getRequestId(), 0, Plc4xResponseCode.ACCESS_DENIED)); + case READ_REQUEST -> send(new Plc4xReadResponse(message.getRequestId(), + ((Plc4xReadRequest) message).getConnectionId(), Plc4xResponseCode.ACCESS_DENIED, + Collections.emptyList())); + case WRITE_REQUEST -> send(new Plc4xWriteResponse(message.getRequestId(), + ((Plc4xWriteRequest) message).getConnectionId(), Plc4xResponseCode.ACCESS_DENIED, + Collections.emptyList())); + default -> { /* nothing to answer */ } + } + return false; + } + + private void handleConnect(Plc4xConnectRequest request) { + try (final PlcConnection ignored = connectionFactory.getConnection(request.getConnectionString())) { + final int connectionId = connectionIdGenerator.getAndIncrement(); + connectionUrls.put(connectionId, request.getConnectionString()); + send(new Plc4xConnectResponse(request.getRequestId(), connectionId, Plc4xResponseCode.OK)); + } catch (Exception e) { + send(new Plc4xConnectResponse(request.getRequestId(), 0, Plc4xResponseCode.INVALID_ADDRESS)); + } + } + + private void handleRead(Plc4xReadRequest request) { + String connectionUrl = connectionUrls.get(request.getConnectionId()); + try (final PlcConnection connection = connectionFactory.getConnection(connectionUrl)) { + final PlcReadRequest.Builder builder = connection.readRequestBuilder(); + for (Plc4xTagRequest requestTag : request.getTags()) { + builder.addTagAddress(requestTag.getTag().getName(), requestTag.getTag().getTagQuery()); + } + final PlcReadRequest rr = builder.build(); + + // Execute synchronously (required when working with the connection cache). + final PlcReadResponse apiReadResponse = rr.execute().get(); + + List tags = new ArrayList<>(apiReadResponse.getTagNames().size()); + for (Plc4xTagRequest plc4xRequestTag : request.getTags()) { + final PlcResponseCode responseCode = + apiReadResponse.getResponseCode(plc4xRequestTag.getTag().getName()); + Plc4xResponseCode resCode; + Plc4xValueType valueType; + PlcValue value; + if (responseCode == PlcResponseCode.OK) { + resCode = Plc4xResponseCode.OK; + value = apiReadResponse.getPlcValue(plc4xRequestTag.getTag().getName()); + final String valueTypeName = value.getClass().getSimpleName(); + // Cut off the "Plc" prefix to get the name of the PlcValueType. + valueType = Plc4xValueType.valueOf(valueTypeName.substring(3)); + } else { + resCode = Plc4xResponseCode.INVALID_ADDRESS; + value = null; + valueType = Plc4xValueType.NULL; } + tags.add(new Plc4xTagValueResponse(plc4xRequestTag.getTag(), resCode, valueType, value)); + } + send(new Plc4xReadResponse(request.getRequestId(), request.getConnectionId(), + Plc4xResponseCode.OK, tags)); + } catch (Exception e) { + logger.error("Error executing read request", e); + send(new Plc4xReadResponse(request.getRequestId(), request.getConnectionId(), + Plc4xResponseCode.INVALID_ADDRESS, Collections.emptyList())); + } + } - case WRITE_REQUEST: - final Plc4xWriteRequest plc4xWriteRequest = (Plc4xWriteRequest) plc4xMessage; - String connectionUrl = connectionUrls.get(plc4xWriteRequest.getConnectionId()); - try (final PlcConnection connection = connectionManager.getConnection(connectionUrl)) { - // Build a write request for all tags in the request. - final PlcWriteRequest.Builder builder = connection.writeRequestBuilder(); - for (Plc4xTagValueRequest plc4xRequestTag : plc4xWriteRequest.getTags()) { - builder.addTagAddress(plc4xRequestTag.getTag().getName(), - plc4xRequestTag.getTag().getTagQuery(), plc4xRequestTag.getValue().getObject()); - } - final PlcWriteRequest apiWriteRequest = builder.build(); - - // Execute the query - // (It has to be synchronously when working with the connection cache) - final PlcWriteResponse apiWriteResponse = apiWriteRequest.execute().get(); - - // Create the response. - List plc4xTags = - new ArrayList<>(apiWriteResponse.getTagNames().size()); - for (Plc4xTagValueRequest plc4xRequestTag : plc4xWriteRequest.getTags()) { - final PlcResponseCode apiResponseCode = - apiWriteResponse.getResponseCode(plc4xRequestTag.getTag().getName()); - Plc4xResponseCode resCode; - if(apiResponseCode == PlcResponseCode.OK) { - resCode = Plc4xResponseCode.OK; - } else { - resCode = Plc4xResponseCode.INVALID_ADDRESS; - } - plc4xTags.add(new Plc4xTagResponse(plc4xRequestTag.getTag(), resCode)); - } - Plc4xWriteResponse plc4xWriteResponse = new Plc4xWriteResponse( - plc4xWriteRequest.getRequestId(), plc4xWriteRequest.getConnectionId(), - Plc4xResponseCode.OK, plc4xTags); - - // Send the response. - ctx.writeAndFlush(plc4xWriteResponse); - } catch (Exception e) { - logger.error("Error executing request", e); - Plc4xWriteResponse response = new Plc4xWriteResponse( - plc4xWriteRequest.getRequestId(), plc4xWriteRequest.getConnectionId(), - Plc4xResponseCode.INVALID_ADDRESS, Collections.emptyList()); - ctx.writeAndFlush(response); - } - break; + private void handleWrite(Plc4xWriteRequest request) { + String connectionUrl = connectionUrls.get(request.getConnectionId()); + try (final PlcConnection connection = connectionFactory.getConnection(connectionUrl)) { + final PlcWriteRequest.Builder builder = connection.writeRequestBuilder(); + for (Plc4xTagValueRequest plc4xRequestTag : request.getTags()) { + builder.addTagAddress(plc4xRequestTag.getTag().getName(), + plc4xRequestTag.getTag().getTagQuery(), plc4xRequestTag.getValue().getObject()); } + final PlcWriteRequest apiWriteRequest = builder.build(); + + // Execute synchronously (required when working with the connection cache). + final PlcWriteResponse apiWriteResponse = apiWriteRequest.execute().get(); + + List plc4xTags = new ArrayList<>(apiWriteResponse.getTagNames().size()); + for (Plc4xTagValueRequest plc4xRequestTag : request.getTags()) { + final PlcResponseCode apiResponseCode = + apiWriteResponse.getResponseCode(plc4xRequestTag.getTag().getName()); + Plc4xResponseCode resCode = apiResponseCode == PlcResponseCode.OK + ? Plc4xResponseCode.OK : Plc4xResponseCode.INVALID_ADDRESS; + plc4xTags.add(new Plc4xTagResponse(plc4xRequestTag.getTag(), resCode)); + } + send(new Plc4xWriteResponse(request.getRequestId(), request.getConnectionId(), + Plc4xResponseCode.OK, plc4xTags)); + } catch (Exception e) { + logger.error("Error executing write request", e); + send(new Plc4xWriteResponse(request.getRequestId(), request.getConnectionId(), + Plc4xResponseCode.INVALID_ADDRESS, Collections.emptyList())); + } + } + + private void send(Plc4xMessage message) { + try { + codec.send(message); + } catch (MessageCodecException e) { + logger.error("Failed to send response", e); } } + /** + * Length-aware constant-time comparison to avoid leaking credential length/content via timing. + */ + private static boolean constantTimeEquals(byte[] expected, byte[] actual) { + return MessageDigest.isEqual(expected, actual); + } + } diff --git a/plc4j/tools/plc4x-server/src/main/java/org/apache/plc4x/java/tools/plc4xserver/protocol/SocketTransportInstance.java b/plc4j/tools/plc4x-server/src/main/java/org/apache/plc4x/java/tools/plc4xserver/protocol/SocketTransportInstance.java new file mode 100644 index 000000000..cc05687e6 --- /dev/null +++ b/plc4j/tools/plc4x-server/src/main/java/org/apache/plc4x/java/tools/plc4xserver/protocol/SocketTransportInstance.java @@ -0,0 +1,121 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +package org.apache.plc4x.java.tools.plc4xserver.protocol; + +import org.apache.plc4x.java.spi.transports.api.RingBuffer; +import org.apache.plc4x.java.spi.transports.api.TransportInstance; +import org.apache.plc4x.java.spi.transports.api.config.TransportConfiguration; +import org.apache.plc4x.java.spi.transports.api.exceptions.TransportException; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.Socket; + +/** + * Adapts an accepted server-side {@link Socket} to the SPI3 {@link TransportInstance} contract + * so the regular PLC4X message codec can be reused on the server side. + * + *

SPI3 has no server transport (it is purely client/outbound), but a {@code MessageCodecBase} + * only needs the byte-level peek/read/write surface. Inbound bytes are pulled from the socket by + * {@link #fill()} into a {@link RingBuffer}; the codec then drains complete messages from that + * buffer via {@link #getNumBytesAvailable()} / {@link #peekReadableBytes(int)} / {@link #read(int)}. + * The maximum PLC4X proxy message is bounded by a {@code uint16} length, so a buffer slightly + * larger than 64 KiB always holds a full frame.

+ */ +public class SocketTransportInstance implements TransportInstance { + + private static final int BUFFER_CAPACITY = 256 * 1024; + private static final int READ_CHUNK = 8 * 1024; + + private final Socket socket; + private final InputStream in; + private final OutputStream out; + private final RingBuffer ringBuffer = new RingBuffer(BUFFER_CAPACITY); + private final byte[] readChunk = new byte[READ_CHUNK]; + + public SocketTransportInstance(Socket socket) throws IOException { + this.socket = socket; + this.in = socket.getInputStream(); + this.out = socket.getOutputStream(); + } + + /** + * Blocks until at least one byte arrives from the socket and appends it to the ring buffer. + * + * @return {@code false} on end-of-stream (peer closed), {@code true} otherwise. + */ + public boolean fill() throws IOException { + int read = in.read(readChunk); + if (read == -1) { + return false; + } + if (read > 0) { + ringBuffer.write(readChunk, 0, read); + } + return true; + } + + @Override + public TransportConfiguration getConfiguration() { + return null; + } + + @Override + public boolean isOpen() { + return !socket.isClosed() && socket.isConnected(); + } + + @Override + public int getNumBytesAvailable() { + return ringBuffer.availableForReading(); + } + + @Override + public byte[] peekReadableBytes(int numBytes) { + return ringBuffer.peek(numBytes); + } + + @Override + public byte[] read(int numBytes) { + return ringBuffer.read(numBytes); + } + + @Override + public void write(byte[] bytes) throws TransportException { + try { + synchronized (out) { + out.write(bytes); + out.flush(); + } + } catch (IOException e) { + throw new TransportException("Failed to write to socket", e); + } + } + + @Override + public void close() throws TransportException { + try { + socket.close(); + } catch (IOException e) { + throw new TransportException("Failed to close socket", e); + } + } + +} diff --git a/plc4j/tools/plc4x-server/src/test/java/org/apache/plc4x/java/tools/plc4xserver/Plc4xServerTest.java b/plc4j/tools/plc4x-server/src/test/java/org/apache/plc4x/java/tools/plc4xserver/Plc4xServerTest.java index 0e3595739..5695fa7f7 100644 --- a/plc4j/tools/plc4x-server/src/test/java/org/apache/plc4x/java/tools/plc4xserver/Plc4xServerTest.java +++ b/plc4j/tools/plc4x-server/src/test/java/org/apache/plc4x/java/tools/plc4xserver/Plc4xServerTest.java @@ -22,13 +22,15 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import org.apache.plc4x.java.DefaultPlcDriverManager; import org.apache.plc4x.java.api.PlcConnection; -import org.apache.plc4x.java.api.PlcConnectionManager; +import org.apache.plc4x.java.api.PlcConnectionFactory; +import org.apache.plc4x.java.api.exceptions.PlcConnectionException; import org.apache.plc4x.java.api.messages.PlcReadRequest; import org.apache.plc4x.java.api.messages.PlcReadResponse; import org.apache.plc4x.java.api.messages.PlcWriteRequest; @@ -40,16 +42,23 @@ public class Plc4xServerTest { + private static final String USERNAME = "test-user"; + private static final String PASSWORD = "test-password"; + private static final Plc4xServer SERVER = new Plc4xServer(); - private static final String CONNECTION_STRING_TEMPLATE = "plc4x://localhost:%d?remote-connection-string=%s"; + // TLS (the default) with verify-ssl=false so the client trusts the server's self-signed cert. + private static final String CONNECTION_STRING_TEMPLATE = + "plc4x:tls://localhost:%d?remote-connection-string=%s&username=%s&password=%s&tls.verify=false"; private static final String CONNECTION_STRING_SIMULATED_ENCODED = "simulated%3A%2F%2Flocalhost"; private static final long TIMEOUT_VALUE = 10; private static final TimeUnit TIMEOUT_UNIT = TimeUnit.SECONDS; - private final PlcConnectionManager connectionManager = new DefaultPlcDriverManager(); + private final PlcConnectionFactory connectionFactory = new DefaultPlcDriverManager(); @BeforeAll public static void setUp() throws ExecutionException, InterruptedException, TimeoutException { + SERVER.setUsername(USERNAME); + SERVER.setPassword(PASSWORD); SERVER.start().get(TIMEOUT_VALUE, TIMEOUT_UNIT); } @@ -58,18 +67,18 @@ public static void tearDown() { SERVER.stop(); } + private String connectionString(String username, String password) { + return String.format(CONNECTION_STRING_TEMPLATE, SERVER.getPort(), + CONNECTION_STRING_SIMULATED_ENCODED, username, password); + } + @Test public void testWrite() throws Exception { final PlcWriteResponse response; - try (PlcConnection connection = connectionManager.getConnection( - String.format(CONNECTION_STRING_TEMPLATE, SERVER.getPort(), CONNECTION_STRING_SIMULATED_ENCODED))) { + try (PlcConnection connection = connectionFactory.getConnection(connectionString(USERNAME, PASSWORD))) { final PlcWriteRequest request = connection.writeRequestBuilder() - .addTagAddress( - "foo", - "STATE/foo:DINT", - 42 - ) + .addTagAddress("foo", "STATE/foo:DINT", 42) .build(); response = request.execute().get(TIMEOUT_VALUE, TIMEOUT_UNIT); } @@ -81,13 +90,9 @@ public void testWrite() throws Exception { public void testRead() throws Exception { final PlcReadResponse response; - try (PlcConnection connection = connectionManager.getConnection( - String.format(CONNECTION_STRING_TEMPLATE, SERVER.getPort(), CONNECTION_STRING_SIMULATED_ENCODED))) { + try (PlcConnection connection = connectionFactory.getConnection(connectionString(USERNAME, PASSWORD))) { final PlcReadRequest request = connection.readRequestBuilder() - .addTagAddress( - "foo", - "RANDOM/foo:DINT" - ) + .addTagAddress("foo", "RANDOM/foo:DINT") .build(); response = request.execute().get(TIMEOUT_VALUE, TIMEOUT_UNIT); } @@ -102,22 +107,14 @@ public void testReadWriteSameConnection() throws Exception { final PlcWriteResponse writeResponse; final PlcReadResponse readResponse; - try (PlcConnection connection = connectionManager.getConnection( - String.format(CONNECTION_STRING_TEMPLATE, SERVER.getPort(), CONNECTION_STRING_SIMULATED_ENCODED))) { + try (PlcConnection connection = connectionFactory.getConnection(connectionString(USERNAME, PASSWORD))) { final PlcWriteRequest writeRequest = connection.writeRequestBuilder() - .addTagAddress( - "foo", - "STATE/foo:DINT", - 21 - ) + .addTagAddress("foo", "STATE/foo:DINT", 21) .build(); writeResponse = writeRequest.execute().get(TIMEOUT_VALUE, TIMEOUT_UNIT); final PlcReadRequest readRequest = connection.readRequestBuilder() - .addTagAddress( - "foo", - "STATE/foo:DINT" - ) + .addTagAddress("foo", "STATE/foo:DINT") .build(); readResponse = readRequest.execute().get(TIMEOUT_VALUE, TIMEOUT_UNIT); } @@ -128,4 +125,48 @@ public void testReadWriteSameConnection() throws Exception { assertInstanceOf(Integer.class, readResponse.getPlcValue("foo").getObject()); assertEquals(21, readResponse.getInteger("foo")); } + + /** + * The server's connection cache now lives and dies with start()/stop(), just like its socket + * and its executor. A server that is stopped and started again therefore has to work with a + * fresh cache rather than a closed one. + */ + @Test + public void testRestartedServerStillServesRequests() throws Exception { + final Plc4xServer server = new Plc4xServer(); + server.setUsername(USERNAME); + server.setPassword(PASSWORD); + try { + server.start().get(TIMEOUT_VALUE, TIMEOUT_UNIT); + server.stop(); + // Stopping twice must stay harmless - closing a closed cache does nothing. + server.stop(); + server.start().get(TIMEOUT_VALUE, TIMEOUT_UNIT); + + final String connectionString = String.format(CONNECTION_STRING_TEMPLATE, server.getPort(), + CONNECTION_STRING_SIMULATED_ENCODED, USERNAME, PASSWORD); + final PlcReadResponse response; + try (PlcConnection connection = connectionFactory.getConnection(connectionString)) { + final PlcReadRequest request = connection.readRequestBuilder() + .addTagAddress("foo", "RANDOM/foo:DINT") + .build(); + response = request.execute().get(TIMEOUT_VALUE, TIMEOUT_UNIT); + } + + assertEquals(PlcResponseCode.OK, response.getResponseCode("foo")); + } finally { + server.stop(); + } + } + + @Test + public void testWrongPasswordIsRejected() { + // A connection with bad credentials must fail during the mandatory auth handshake. + assertThrows(PlcConnectionException.class, () -> { + try (PlcConnection ignored = connectionFactory.getConnection( + connectionString(USERNAME, "wrong-password"))) { + // Should never get here. + } + }); + } } diff --git a/plc4j/tools/pom.xml b/plc4j/tools/pom.xml index 970e3c7bf..2b3beb0d7 100644 --- a/plc4j/tools/pom.xml +++ b/plc4j/tools/pom.xml @@ -17,14 +17,12 @@ specific language governing permissions and limitations under the License. --> - - - 4.0.0 + - org.apache.plc4x + org.apache.plc4x.extras plc4j-extras - 0.13.0-SNAPSHOT + 1.0.0-SNAPSHOT plc4j-tools @@ -34,38 +32,7 @@ A collection of tools that provide additional services to users. - 2024-02-16T14:53:02Z + 2026-09-20T15:22:10Z - - plc4x-server - - - - - - .above-or-equal-java-17 - - [17,) - - - opcua-server - - - - - - \ No newline at end of file diff --git a/plc4j/tools/ui/application/pom.xml b/plc4j/tools/ui/application/pom.xml deleted file mode 100644 index 6a2cb7249..000000000 --- a/plc4j/tools/ui/application/pom.xml +++ /dev/null @@ -1,179 +0,0 @@ - - - - 4.0.0 - - - org.apache.plc4x - plc4j-tools-ui - 0.13.0-SNAPSHOT - - - plc4j-tools-ui-application - PLC4J: Tools: Application - - - - - - org.apache.maven.plugins - maven-dependency-plugin - 3.6.1 - - - unpack - generate-resources - - unpack - - - - - org.apache.plc4x - plc4j-tools-ui-frontend - ${project.version} - jar - ${project.build.outputDirectory}/static - - - - - - copy-dependencies - package - - copy-dependencies - - - ${project.build.directory}/libs - - - - - - org.apache.plc4x:plc4j-tools-ui-backend - ch.qos.logback:logback-core - - - - - org.springframework.boot - spring-boot-maven-plugin - 3.2.5 - - org.apache.plc4x.java.tools.ui.Plc4xUiBackendApplication - - dev - - - - - org.apache.rat - apache-rat-plugin - - - license-check - verify - - check - - - - storage/** - derby.log - - - - - - - - - - - org.springframework.boot - spring-boot - - - org.springframework.boot - spring-boot-autoconfigure - - - org.springframework - spring-context - - - org.springframework.data - spring-data-jpa - - - - org.apache.plc4x - plc4j-tools-ui-backend - 0.13.0-SNAPSHOT - - - - org.slf4j - slf4j-api - - - ch.qos.logback - logback-classic - compile - - - ch.qos.logback - logback-core - compile - - - - - org.springframework.boot - spring-boot-starter-test - test - - - org.springframework.boot - spring-boot-test - test - - - org.springframework - spring-test - test - - - org.springframework.security - spring-security-test - test - - - - - org.apache.plc4x - plc4j-tools-ui-frontend - 0.13.0-SNAPSHOT - provided - - - - diff --git a/plc4j/tools/ui/backend/pom.xml b/plc4j/tools/ui/backend/pom.xml deleted file mode 100644 index 896068ff8..000000000 --- a/plc4j/tools/ui/backend/pom.xml +++ /dev/null @@ -1,144 +0,0 @@ - - - - 4.0.0 - - - org.apache.plc4x - plc4j-tools-ui - 0.13.0-SNAPSHOT - - - plc4j-tools-ui-backend - PLC4J: Tools: Backend - - - - - org.apache.rat - apache-rat-plugin - - - license-check - verify - - check - - - - - - storage/** - derby.log - - - - - - - - - org.springframework.boot - spring-boot-starter-web - - - org.springframework.boot - spring-boot-starter-websocket - - - org.springframework - spring-core - - - org.springframework - spring-context - - - org.springframework - spring-web - - - org.springframework.data - spring-data-jpa - - - org.springframework - spring-websocket - - - - - org.springframework.boot - spring-boot-starter-data-jpa - - - org.apache.derby - derby - - - org.apache.derby - derbytools - - - - org.apache.plc4x - plc4j-api - 0.13.0-SNAPSHOT - - - org.apache.plc4x - plc4j-connection-cache - 0.13.0-SNAPSHOT - - - org.apache.plc4x - plc4j-driver-all - 0.13.0-SNAPSHOT - pom - - - - jakarta.persistence - jakarta.persistence-api - - - com.fasterxml.jackson.core - jackson-databind - - - - - org.slf4j - slf4j-api - - - ch.qos.logback - logback-classic - - - ch.qos.logback - logback-core - - - - diff --git a/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/configuration/WebSocketConfig.java b/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/configuration/WebSocketConfig.java deleted file mode 100644 index 03281de19..000000000 --- a/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/configuration/WebSocketConfig.java +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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. - */ - -package org.apache.plc4x.java.tools.ui.configuration; - -import org.apache.plc4x.java.tools.ui.handler.WebSocketHandler; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.socket.config.annotation.EnableWebSocket; -import org.springframework.web.socket.config.annotation.WebSocketConfigurer; -import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; - -@Configuration -@EnableWebSocket -public class WebSocketConfig implements WebSocketConfigurer { - - private final WebSocketHandler webSocketHandler; - - public WebSocketConfig(WebSocketHandler webSocketHandler) { - this.webSocketHandler = webSocketHandler; - } - - @Override - public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { - registry.addHandler(webSocketHandler, "/ws").setAllowedOrigins("*"); - } - -} diff --git a/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/controller/DeviceController.java b/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/controller/DeviceController.java deleted file mode 100644 index 5a4f66a2b..000000000 --- a/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/controller/DeviceController.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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. - */ - -package org.apache.plc4x.java.tools.ui.controller; - -import org.apache.plc4x.java.tools.ui.model.Device; -import org.apache.plc4x.java.tools.ui.service.DeviceService; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -//@CrossOrigin(origins = {"http://localhost:8080", "http://localhost:5173"}) -@CrossOrigin(origins = {"*"}) -@RestController -@RequestMapping("/api/devices") -public class DeviceController { - - private final DeviceService deviceService; - - public DeviceController(DeviceService deviceService) { - this.deviceService = deviceService; - } - - @GetMapping("/{id}") - public Device getDeviceById(@PathVariable Integer id) { - return deviceService.readDevice(id).orElseThrow(() -> new RuntimeException("Error finding connection with id: " + id)); - } - - @GetMapping - public List getAllDevices() { - return deviceService.getAllDevices(); - } - - @PostMapping - public Device saveDevice(@RequestBody Device device) { - if (device.getId() == null) { - return deviceService.createDevice(device); - } else { - return deviceService.updateDevice(device); - } - } - - @DeleteMapping - public void deleteDevice(@RequestBody Device device) { - deviceService.deleteDevice(device); - } - -} diff --git a/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/controller/DriverController.java b/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/controller/DriverController.java deleted file mode 100644 index 43438f2e1..000000000 --- a/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/controller/DriverController.java +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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. - */ - -package org.apache.plc4x.java.tools.ui.controller; - -import org.apache.plc4x.java.tools.ui.model.Driver; -import org.apache.plc4x.java.tools.ui.service.DriverService; -import org.springframework.web.bind.annotation.*; - -import java.util.List; - -// Allow from the default port 8080 as well as the one node usually uses for it's dev-mode 5173 -//@CrossOrigin(origins = {"http://localhost:8080", "http://localhost:5173"}) -@CrossOrigin(origins = {"*"}) -@RestController -@RequestMapping("/api") -public class DriverController { - - private final DriverService driverService; - - public DriverController(DriverService driverService) { - this.driverService = driverService; - } - - @GetMapping("/drivers") - public List getAllDrivers() { - return driverService.getDriverList(); - } - - @GetMapping("/discover/{protocolCode}") - public void discover(@PathVariable("protocolCode") String protocolCode) { - driverService.discover(protocolCode); - } - -} diff --git a/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/handler/WebSocketHandler.java b/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/handler/WebSocketHandler.java deleted file mode 100644 index bd7e10c52..000000000 --- a/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/handler/WebSocketHandler.java +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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. - */ - -package org.apache.plc4x.java.tools.ui.handler; - -import com.fasterxml.jackson.databind.ObjectMapper; -import org.apache.plc4x.java.tools.ui.event.UiApplicationEvent; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Component; -import org.springframework.web.socket.CloseStatus; -import org.springframework.web.socket.TextMessage; -import org.springframework.web.socket.WebSocketMessage; -import org.springframework.web.socket.WebSocketSession; -import org.springframework.lang.Nullable; - -import java.io.IOException; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -@Component -public class WebSocketHandler implements org.springframework.web.socket.WebSocketHandler { - - private final Map openSessions = new ConcurrentHashMap<>(); - private final ObjectMapper objectMapper = new ObjectMapper(); - - /** - * ApplicationEvent listener forwarding all ApplicationEvents from this application to all connected clients. - * @param uiApplicationEvent event that we want to be forwarded. - */ - @EventListener - public void onApplicationEvent(UiApplicationEvent uiApplicationEvent) { - openSessions.forEach((s, webSocketSession) -> { - try { - webSocketSession.sendMessage(new TextMessage(objectMapper.writeValueAsString(uiApplicationEvent))); - } catch (IOException e) { - throw new RuntimeException(e); - } - }); - } - - /** - * Register a new web-socket session. - * @param session the new web-socket session. - */ - @Override - public void afterConnectionEstablished(@Nullable WebSocketSession session) { - if(session != null) { - openSessions.put(session.getId(), session); - } - } - - /** - * Remove a web-socket session from the list. - * @param session the web-socket session we want to remove - * @param closeStatus the status of the closed session - */ - @Override - public void afterConnectionClosed(@Nullable WebSocketSession session, @Nullable CloseStatus closeStatus) { - if((session != null) && (closeStatus != null)) { - //noinspection resource - openSessions.remove(session.getId()); - } - } - - /** - * Handle an incoming message on the web-socket session - * (this should actually not happen, as we only use the web-sockets for server-to-client communication) - * @param session the web-socket session the message is coming in on - * @param message the message - */ - @Override - public void handleMessage(@Nullable WebSocketSession session, @Nullable WebSocketMessage message) { - System.out.println("handleMessage"); - } - - /** - * Callback for handling transport errors. - * @param session the web-socket session causing the error - * @param exception the error that happened - */ - @Override - public void handleTransportError(@Nullable WebSocketSession session, @Nullable Throwable exception) { - System.out.println("handleTransportError"); - } - - @Override - public boolean supportsPartialMessages() { - return false; - } - -} diff --git a/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/model/Device.java b/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/model/Device.java deleted file mode 100644 index bbc83963b..000000000 --- a/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/model/Device.java +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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. - */ - -package org.apache.plc4x.java.tools.ui.model; - -import jakarta.persistence.*; - -import java.util.Map; - -@Entity -public class Device { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Integer id; - - private String name; - - private String protocolCode; - private String transportCode; - - private String transportUrl; - @ElementCollection - @MapKeyColumn(name="name") - @Column(name="value") - @CollectionTable(name="device_options", joinColumns=@JoinColumn(name="device_id")) - private Map options; - @ElementCollection - @MapKeyColumn(name="name") - @Column(name="value") - @CollectionTable(name="device_attributes", joinColumns=@JoinColumn(name="device_id")) - private Map attributes; - - public Device() { - } - - public Integer getId() { - return id; - } - - public void setId(Integer id) { - this.id = id; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getProtocolCode() { - return protocolCode; - } - - public void setProtocolCode(String protocolCode) { - this.protocolCode = protocolCode; - } - - public String getTransportCode() { - return transportCode; - } - - public void setTransportCode(String transportCode) { - this.transportCode = transportCode; - } - - public String getTransportUrl() { - return transportUrl; - } - - public void setTransportUrl(String transportUrl) { - this.transportUrl = transportUrl; - } - - public Map getOptions() { - return options; - } - - public void setOptions(Map options) { - this.options = options; - } - - public Map getAttributes() { - return attributes; - } - - public void setAttributes(Map attributes) { - this.attributes = attributes; - } - -} diff --git a/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/repository/DeviceRepository.java b/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/repository/DeviceRepository.java deleted file mode 100644 index 3a5bd46b9..000000000 --- a/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/repository/DeviceRepository.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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. - */ - -package org.apache.plc4x.java.tools.ui.repository; - -import org.apache.plc4x.java.tools.ui.model.Device; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.List; - -public interface DeviceRepository extends JpaRepository { - List findByProtocolCodeAndTransportCodeAndTransportUrl(String protocolCode, String transportCode, String transportUrl); - -} diff --git a/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/service/DeviceService.java b/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/service/DeviceService.java deleted file mode 100644 index 24591d693..000000000 --- a/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/service/DeviceService.java +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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. - */ - -package org.apache.plc4x.java.tools.ui.service; - -import org.apache.plc4x.java.api.PlcDriverManager; -import org.apache.plc4x.java.tools.ui.event.DeviceEvent; -import org.apache.plc4x.java.tools.ui.event.EventType; -import org.apache.plc4x.java.tools.ui.model.Device; -import org.apache.plc4x.java.tools.ui.repository.DeviceRepository; -import org.apache.plc4x.java.utils.cache.CachedPlcConnectionManager; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Optional; - -@Component -public class DeviceService { - - private final DeviceRepository deviceRepository; - private final ApplicationEventPublisher publisher; - private final CachedPlcConnectionManager cachedPlcConnectionManager; - - public DeviceService(DeviceRepository deviceRepository, ApplicationEventPublisher publisher, PlcDriverManager driverManager) { - this.deviceRepository = deviceRepository; - this.publisher = publisher; - this.cachedPlcConnectionManager = CachedPlcConnectionManager.getBuilder(driverManager.getConnectionManager()).build(); - } - - public List getAllDevices() { - return deviceRepository.findAll(); - } - - /** - * We consider a device a "new device", if there is no device with the same protocol code, transport code and transport url. - * @param device the device we want to know if it's "new". - * @return true if it's a "new" device. - */ - public boolean isNewDevice(Device device) { - List foundDevices = deviceRepository.findByProtocolCodeAndTransportCodeAndTransportUrl(device.getProtocolCode(), device.getTransportCode(), device.getTransportUrl()); - return foundDevices.isEmpty(); - } - - public Device createDevice(Device device) { - Device savedDevice = deviceRepository.save(device); - publisher.publishEvent(new DeviceEvent(device, EventType.CREATED)); - return savedDevice; - } - - public Optional readDevice(Integer id) { - return deviceRepository.findById(id); - } - - public Device updateDevice(Device device) { - Device savedDevice = deviceRepository.save(device); - publisher.publishEvent(new DeviceEvent(savedDevice, EventType.UPDATED)); - return savedDevice; - } - - public void deleteDevice(Device device) { - deviceRepository.delete(device); - publisher.publishEvent(new DeviceEvent(device, EventType.DELETED)); - } - -} diff --git a/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/service/DriverService.java b/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/service/DriverService.java deleted file mode 100644 index 79fa8b504..000000000 --- a/plc4j/tools/ui/backend/src/main/java/org/apache/plc4x/java/tools/ui/service/DriverService.java +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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. - */ - -package org.apache.plc4x.java.tools.ui.service; - -import org.apache.plc4x.java.api.PlcDriver; -import org.apache.plc4x.java.api.PlcDriverManager; -import org.apache.plc4x.java.api.exceptions.PlcConnectionException; -import org.apache.plc4x.java.api.messages.PlcDiscoveryRequest; -import org.apache.plc4x.java.api.metadata.PlcDriverMetadata; -import org.apache.plc4x.java.tools.ui.model.Device; -import org.apache.plc4x.java.tools.ui.model.Driver; -import org.springframework.stereotype.Component; - -import java.util.*; - -@Component -public class DriverService { - - private static final String ALL_DRIVERS = "all"; - - private final PlcDriverManager driverManager; - private final DeviceService deviceService; - - public DriverService(PlcDriverManager driverManager, DeviceService deviceService) { - this.driverManager = driverManager; - this.deviceService = deviceService; - } - - public List getDriverList() { - List drivers = new ArrayList<>(); - for (String protocolCode : driverManager.getProtocolCodes()) { - try { - PlcDriver driver = driverManager.getDriver(protocolCode); - PlcDriverMetadata metadata = driver.getMetadata(); - drivers.add(new Driver(protocolCode, driver.getProtocolName(), metadata)); - } catch (Exception e) { - throw new RuntimeException("Error retrieving driver list", e); - } - } - return drivers; - } - - public void discover(String protocolCode) { - if(ALL_DRIVERS.equals(protocolCode)) { - for (String curProtocolCode : driverManager.getProtocolCodes()) { - try { - if("modbus-tcp".equals(curProtocolCode)) { - continue; - } - PlcDriver driver = driverManager.getDriver(curProtocolCode); - if (driver.getMetadata().isDiscoverySupported()) { - discoverProtocol(curProtocolCode); - } - } catch (PlcConnectionException e) { - throw new RuntimeException(e); - } - } - } else { - discoverProtocol(protocolCode); - } - } - - private void discoverProtocol(String protocolCode) { - try { - PlcDriver driver = driverManager.getDriver(protocolCode); - if (!driver.getMetadata().isDiscoverySupported()) { - throw new RuntimeException("Driver doesn't support discovery"); - } else { - PlcDiscoveryRequest request = driver.discoveryRequestBuilder().addQuery("all", "*").build(); - // Execute the discovery request and have all connections found be added as connections. - request.executeWithHandler(discoveryItem -> { - // Create the new device. - Device device = new Device(); - device.setName(discoveryItem.getName()); - device.setProtocolCode(discoveryItem.getProtocolCode()); - device.setTransportCode(discoveryItem.getTransportCode()); - device.setTransportUrl(discoveryItem.getTransportUrl()); - device.setOptions(discoveryItem.getOptions()); - Map attributes = new HashMap<>(); - for (String attributeName : discoveryItem.getAttributes().keySet()) { - String attributeValue = discoveryItem.getAttributes().get(attributeName).getString(); - attributes.put(attributeName, attributeValue); - } - device.setAttributes(attributes); - - // Save the found device in the database, if this is a new device, - // that is not stored in our system before. - if(deviceService.isNewDevice(device)) { - deviceService.createDevice(device); - } - }).whenComplete((plcDiscoveryResponse, throwable) -> { - if(throwable != null) { - throw new RuntimeException("Error executing discovery", throwable); - } - }); - } - } catch (PlcConnectionException e) { - throw new RuntimeException("Error getting driver", e); - } - } - -} diff --git a/plc4j/tools/ui/backend/ws.html b/plc4j/tools/ui/backend/ws.html deleted file mode 100644 index 492e0ae10..000000000 --- a/plc4j/tools/ui/backend/ws.html +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - Profile notification client - - - - - - diff --git a/plc4j/tools/ui/frontend/pom.xml b/plc4j/tools/ui/frontend/pom.xml deleted file mode 100644 index 815a35c47..000000000 --- a/plc4j/tools/ui/frontend/pom.xml +++ /dev/null @@ -1,212 +0,0 @@ - - - - 4.0.0 - - - org.apache.plc4x - plc4j-tools-ui - 0.13.0-SNAPSHOT - - - plc4j-tools-ui-frontend - PLC4J: Tools: Frontend - - - - - - - - cz.habarta.typescript-generator - typescript-generator-maven-plugin - 3.2.1263 - - - generate-rest-client - generate-sources - - generate - - - false - - - - org.apache.plc4x.java.tools.ui.model.Device - org.apache.plc4x.java.tools.ui.model.Driver - org.apache.plc4x.java.tools.ui.event.EventType - org.apache.plc4x.java.tools.ui.event.UiApplicationEvent - org.apache.plc4x.java.tools.ui.event.DeviceEvent - - org.apache.plc4x.java.tools.ui.controller.DeviceController - org.apache.plc4x.java.tools.ui.controller.DriverController - - - java.lang.Comparable - - - cz.habarta.typescript.generator.ext.AxiosClientExtension - - module - implementationFile - true - true - true - jackson2 - AxiosRequestConfig - AxiosPromise - project/src/generated/plc4j-tools-ui-frontend.ts - true - - - - - - cz.habarta.typescript-generator - typescript-generator-spring - 3.2.1263 - - - - - org.codehaus.mojo - license-maven-plugin - 2.4.0 - - - add-apache-headers - process-sources - - update-file-header - - - apache_v2 - - project/src/generated - - - - - - - com.github.eirslett - frontend-maven-plugin - 1.15.0 - - - install-node-and-npm - generate-resources - - install-node-and-npm - - - - npm-install - generate-resources - - npm - - - install - - - - npm-run-build - compile - - npm - - - run build - - - - - project - v20.9.0 - - - - org.apache.maven.plugins - maven-jar-plugin - 3.4.1 - - - default-jar - package - - jar - - - project/dist - - ** - - - - - - - - org.apache.rat - apache-rat-plugin - - - project/dist/** - project/node/** - project/node_modules/** - - - - - - - - - org.apache.plc4x - plc4j-api - 0.13.0-SNAPSHOT - provided - - - org.apache.plc4x - plc4j-tools-ui-backend - 0.13.0-SNAPSHOT - provided - - - - diff --git a/plc4j/tools/ui/frontend/project/.eslintrc.cjs b/plc4j/tools/ui/frontend/project/.eslintrc.cjs deleted file mode 100644 index 3bca75826..000000000 --- a/plc4j/tools/ui/frontend/project/.eslintrc.cjs +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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. - */ - -module.exports = { - root: true, - env: { browser: true, es2020: true }, - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - 'plugin:react-hooks/recommended', - ], - ignorePatterns: ['dist', '.eslintrc.cjs'], - parser: '@typescript-eslint/parser', - plugins: ['react-refresh'], - rules: { - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, - ], - }, -} diff --git a/plc4j/tools/ui/frontend/project/.gitignore b/plc4j/tools/ui/frontend/project/.gitignore deleted file mode 100644 index 7ec0259c8..000000000 --- a/plc4j/tools/ui/frontend/project/.gitignore +++ /dev/null @@ -1,26 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Inspect directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? -/package-lock.json -/node/ \ No newline at end of file diff --git a/plc4j/tools/ui/frontend/project/README.md b/plc4j/tools/ui/frontend/project/README.md deleted file mode 100644 index 8b12d8dbb..000000000 --- a/plc4j/tools/ui/frontend/project/README.md +++ /dev/null @@ -1,49 +0,0 @@ - - -# React + TypeScript + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: - -- Configure the top-level `parserOptions` property like this: - -```js -export default { - // other rules... - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - project: ['./tsconfig.json'], - tsconfigRootDir: __dirname, - }, -} -``` - -- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` -- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` -- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list diff --git a/plc4j/tools/ui/frontend/project/package.json b/plc4j/tools/ui/frontend/project/package.json deleted file mode 100644 index a8072d632..000000000 --- a/plc4j/tools/ui/frontend/project/package.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "name": "frontend", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", - "preview": "vite preview" - }, - "dependencies": { - "@emotion/react": "^11.11.1", - "@emotion/styled": "^11.11.0", - "@mdi/js": "^7.3.67", - "@mdi/react": "^1.6.1", - "@mui/icons-material": "^5.14.19", - "@mui/material": "^5.15.5", - "@reduxjs/toolkit": "^2.0.1", - "axios": "^1.6.2", - "primeflex": "^3.3.1", - "primeicons": "^6.0.1", - "primereact": "10.2.1", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-redux": "^9.0.4", - "react-router-dom": "^6.20.1", - "react-use-websocket": "^4.5.0" - }, - "devDependencies": { - "@types/react": "^18.2.37", - "@types/react-dom": "^18.2.15", - "@typescript-eslint/eslint-plugin": "^6.10.0", - "@typescript-eslint/parser": "^6.10.0", - "@vitejs/plugin-react": "^4.2.0", - "eslint": "^8.53.0", - "eslint-plugin-react-hooks": "^4.6.0", - "eslint-plugin-react-refresh": "^0.4.4", - "typescript": "^5.2.2", - "vite": "^5.0.0" - }, - "proxy": { - "/ws": { - "target": "ws://localhost:8080", - "ws": true - }, - "/": { - "target": "http://localhost:8080" - } - } -} diff --git a/plc4j/tools/ui/frontend/project/public/plc4x-favicon.png b/plc4j/tools/ui/frontend/project/public/plc4x-favicon.png deleted file mode 100644 index 9b4157191..000000000 Binary files a/plc4j/tools/ui/frontend/project/public/plc4x-favicon.png and /dev/null differ diff --git a/plc4j/tools/ui/frontend/project/src/App.tsx b/plc4j/tools/ui/frontend/project/src/App.tsx deleted file mode 100644 index 3cb58461b..000000000 --- a/plc4j/tools/ui/frontend/project/src/App.tsx +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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. - */ - -import './App.css' -import axios from 'axios'; -import {createBrowserRouter, RouterProvider} from "react-router-dom"; -import MainLayout from "./layouts/MainLayout.tsx"; -import Inspect from "./pages/Inspect.tsx"; -import OpcUa from "./pages/OpcUa.tsx"; -import Mqtt from "./pages/Mqtt.tsx"; -import Settings from "./pages/Settings.tsx"; -import About from "./pages/About.tsx"; -import useWebSocket from 'react-use-websocket'; -import {useState} from "react"; -import {DeviceEvent, RestApplicationClient} from "./generated/plc4j-tools-ui-frontend.ts"; -import store, { - addDevice, - deleteDevice, - DeviceAction, - InitializeConnectionsAction, - initializeLists, - updateDevice -} from "./store"; - -axios.defaults.baseURL = 'http://localhost:8080'; -const restClient = new RestApplicationClient(axios); - -// We're actually just using this concept in order to separate the layout for the general page from the content. -const router = createBrowserRouter([ - { - path: '/', - element: , - children: [ - {path: '/inspect', element: }, - {path: '/opcua', element: }, - {path: '/mqtt', element: }, - {path: '/settings', element: }, - {path: '/about', element: }, - ] - }, -]) - -function App() { - const [initialized, setInitialized] = useState(false) - - useWebSocket( 'ws://localhost:8080/ws', { - onOpen: () => { - console.log('WebSocket connection established.'); - }, - onMessage: event => { - const deviceEvent = JSON.parse(event.data) as DeviceEvent; - if(deviceEvent != null) { - switch (deviceEvent.eventType) { - case "CREATED": { - const action:DeviceAction = {device: deviceEvent.source} - store.dispatch(addDevice(action)); - break; - } - case "UPDATED": { - const action:DeviceAction = {device: deviceEvent.source} - store.dispatch(updateDevice(action)); - break; - } - case "DELETED": { - const action: DeviceAction = {device: deviceEvent.source} - store.dispatch(deleteDevice(action)); - break; - } - } - } - } - }); - - // Load the initial list of drivers and connections and initialize the store with that. - if(!initialized) { - setInitialized(true); - restClient.getAllDrivers().then(driverList => { - restClient.getAllDevices().then(deviceList => { - const action:InitializeConnectionsAction = {driverList:driverList.data, deviceList: deviceList.data} - store.dispatch(initializeLists(action)) - }) - }) - } - - return ( - - ) -} - -export default App diff --git a/plc4j/tools/ui/frontend/project/src/assets/plc4x-logo.svg b/plc4j/tools/ui/frontend/project/src/assets/plc4x-logo.svg deleted file mode 100644 index b34c1c3b0..000000000 --- a/plc4j/tools/ui/frontend/project/src/assets/plc4x-logo.svg +++ /dev/null @@ -1,1494 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Asset 1 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/plc4j/tools/ui/frontend/project/src/assets/react.svg b/plc4j/tools/ui/frontend/project/src/assets/react.svg deleted file mode 100644 index d150b944d..000000000 --- a/plc4j/tools/ui/frontend/project/src/assets/react.svg +++ /dev/null @@ -1,23 +0,0 @@ - - \ No newline at end of file diff --git a/plc4j/tools/ui/frontend/project/src/components/DeviceDialog.tsx b/plc4j/tools/ui/frontend/project/src/components/DeviceDialog.tsx deleted file mode 100644 index fd98a3921..000000000 --- a/plc4j/tools/ui/frontend/project/src/components/DeviceDialog.tsx +++ /dev/null @@ -1,184 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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. - */ - -import {Device} from "../generated/plc4j-tools-ui-frontend.ts"; -import {Dialog} from "primereact/dialog"; -import {Button} from "primereact/button"; -import {InputText} from "primereact/inputtext"; -import {DataTable} from "primereact/datatable"; -import {Column, ColumnEditorOptions, ColumnEvent} from "primereact/column"; -import React from "react"; - -interface ConnectionDialogProps { - device: Device - visible: boolean - - onUpdate: (device:Device) => void - onSave: (device:Device) => void - onCancel: () => void -} - -interface TableEntry { - key: string - value: string -} - -type Dictionary = { [index: string]: string } - -function mapToTableEntry(map : Dictionary):TableEntry[] { - let tableEntries = [] as TableEntry[] - for(const key in map) { - const value = map[key] - tableEntries = [...tableEntries, {key: key, value: value}] - } - return tableEntries -} - -export default function DeviceDialog({device, visible, onUpdate, onSave, onCancel}: ConnectionDialogProps) { - - function handleSetName(value: string) { - onUpdate({ - id: device.id, - name: value, - protocolCode: device.protocolCode, - transportCode: device.transportCode, - transportUrl: device.transportUrl, - options: device.options, - attributes: device.attributes, - }) - } - - function handleSetProtocolCode(value: string) { - onUpdate({ - id: device.id, - name: device.name, - protocolCode: value, - transportCode: device.transportCode, - transportUrl: device.transportUrl, - options: device.options, - attributes: device.attributes, - }) - } - - function handleSetTransportCode(value: string) { - onUpdate({ - id: device.id, - name: device.name, - protocolCode: device.protocolCode, - transportCode: value, - transportUrl: device.transportUrl, - options: device.options, - attributes: device.attributes, - }) - } - - function handleSetTransportUrl(value: string) { - onUpdate({ - id: device.id, - name: device.name, - protocolCode: device.protocolCode, - transportCode: device.transportCode, - transportUrl: value, - options: device.options, - attributes: device.attributes, - }) - } - - function handleSave() { - onSave(device) - } - function handleCancel() { - onCancel() - } - - const onCellEditComplete = (e: ColumnEvent) => { - const { rowData, newValue, field, originalEvent: event } = e; - - if (newValue.trim().length > 0) { - rowData[field] = newValue; - } else { - event.preventDefault(); - } - }; - - const cellEditor = (options: ColumnEditorOptions) => { - return textEditor(options); - }; - - const textEditor = (options: ColumnEditorOptions) => { - return ) => { - if(options.editorCallback) { - options.editorCallback(e.target.value) - } - }} />; - }; - - return ( - handleCancel()}> -
- -
- handleSetName(e.target.value)}/> -
-
-
- -
- handleSetProtocolCode(e.target.value)}/> -
-
-
- -
- handleSetTransportCode(e.target.value)}/> -
-
-
- -
- handleSetTransportUrl(e.target.value)}/> -
-
-
- -
- - - cellEditor(options)} onCellEditComplete={onCellEditComplete}/> - -
-
-
- -
- - - cellEditor(options)} onCellEditComplete={onCellEditComplete}/> - -
-
-
-
-
-
-
- ) -} \ No newline at end of file diff --git a/plc4j/tools/ui/frontend/project/src/components/NavigationTree.tsx b/plc4j/tools/ui/frontend/project/src/components/NavigationTree.tsx deleted file mode 100644 index fdd6c8b5c..000000000 --- a/plc4j/tools/ui/frontend/project/src/components/NavigationTree.tsx +++ /dev/null @@ -1,221 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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. - */ - -import {TreeItemData} from "../model/TreeItemData.ts"; -import {Tree} from "primereact/tree"; -import {TreeNode} from "primereact/treenode"; -import {IconType} from "primereact/utils"; -import 'primeicons/primeicons.css'; -import {useRef, useState} from "react"; -import {ContextMenu} from "primereact/contextmenu"; -import {MenuItem} from "primereact/menuitem"; -import {Device, RestApplicationClient} from "../generated/plc4j-tools-ui-frontend.ts"; -import axios from "axios"; -import {Counter} from "../utils/Counter.ts"; -import DeviceDialog from "./DeviceDialog.tsx"; -import {confirmDialog, ConfirmDialog} from "primereact/confirmdialog"; - -type NavigationTreeProps = { - treeItems: TreeItemData[]; -} - -const restClient = new RestApplicationClient(axios); - -export default function NavigationTree({treeItems}: NavigationTreeProps) { - const [dialogDevice, setDialogDevice] = useState({ - id: 0, - name: "", - protocolCode: "", - transportCode: "", - transportUrl: "", - options: {}, - attributes: {}, - }) - const [showDeviceDialog, setShowDeviceDialog] = useState(false) - - const cm = useRef(null); - const menu = [ - { - key: "1", - label: 'Discover', - data: "discover-data", - icon: 'pi pi-search', - disabled: false, - } as MenuItem, - { - key: "2", - label: 'Add', - data: "add-data", - icon: 'pi pi-plus-circle', - disabled: false, - } as MenuItem, - { - key: "3", - label: 'Edit', - data: "edit-data", - icon: 'pi pi-pencil', - disabled: false, - } as MenuItem, - { - key: "4", - label: 'Delete', - data: "delete-data", - icon: 'pi pi-minus-circle', - disabled: false, - } as MenuItem, - { - key: "5", - label: 'Connect', - data: "connect-data", - icon: 'pi pi-phone', - disabled: false, - } as MenuItem - ] as MenuItem[] - - function updateMenu(selectedItem: TreeItemData) { - // Discover - menu[0].disabled = !selectedItem.supportsDiscovery && selectedItem.type != "ROOT" - menu[0].command = () => { - restClient.discover(selectedItem.id) - } - - // Add - menu[1].disabled = selectedItem.type != "DRIVER" - menu[1].command = () => { - dialogDevice.id = 0; - dialogDevice.name = ""; - dialogDevice.transportCode = ""; - dialogDevice.transportUrl = ""; - dialogDevice.protocolCode = selectedItem.id; - dialogDevice.options = {}; - dialogDevice.attributes = {}; - setShowDeviceDialog(true); - } - - // Edit - menu[2].disabled = selectedItem.type != "DEVICE" - menu[2].command = () => { - if(selectedItem.device) { - dialogDevice.id = selectedItem.device.id; - dialogDevice.name = selectedItem.device.name; - dialogDevice.transportCode = selectedItem.device.transportCode; - dialogDevice.transportUrl = selectedItem.device.transportUrl; - dialogDevice.protocolCode = selectedItem.device.protocolCode; - dialogDevice.options = selectedItem.device.options; - dialogDevice.attributes = selectedItem.device.attributes; - setShowDeviceDialog(true); - } - } - // Delete - menu[3].disabled = selectedItem.type != "DEVICE" - menu[3].command = () => { - if(selectedItem.device) { - dialogDevice.id = selectedItem.device.id; - dialogDevice.name = selectedItem.device.name; - dialogDevice.transportCode = selectedItem.device.transportCode; - dialogDevice.transportUrl = selectedItem.device.transportUrl; - dialogDevice.protocolCode = selectedItem.device.protocolCode; - dialogDevice.options = selectedItem.device.options; - dialogDevice.attributes = selectedItem.device.attributes; - confirmDelete() - } - } - // Connect - menu[4].disabled = selectedItem.type != "DEVICE" - menu[4].command = () => { - // TODO: Open a new tab with the connection ... - } - } - - function getIcon(curItem: TreeItemData): IconType { - switch (curItem.type) { - case "DRIVER": - return "pi pi-fw pi-folder-open"//"material-icons md-18 folder_open" - case "CONNECTION": - return "pi pi-fw pi-phone"//"material-icons md-18 tty" - case "DEVICE": - return "pi pi-fw"//"material-icons md-18 computer" - - // discover: "Radar" - // browse: "Manage Search" - // read: "Keyboard Arrow Down" - // write: "Keyboard Arrow Up" - // subscribe: "Keyboard Double Arrow Down" - // publish: "Keyboard Double Arrow Up" - } - } - - function createTreeNode(curItem: TreeItemData, keyGenerator: Counter): TreeNode { - return { - key: keyGenerator.getAndIncrement(), - id: curItem.id, - label: curItem.name, - icon: getIcon(curItem), - data: curItem, - children: curItem.children?.map(value => createTreeNode(value, keyGenerator)) - } - } - - const confirmDeleteAccept = () => { - restClient.deleteDevice(dialogDevice) - } - - const confirmDeleteReject = () => { - } - - function confirmDelete() { - confirmDialog({ - message: 'Do you want to delete this record?', - header: 'Delete Confirmation', - icon: 'pi pi-info-circle', - acceptClassName: 'p-button-danger', - accept: confirmDeleteAccept, - reject: confirmDeleteReject - }); - } - - const treeNodes: TreeNode[] = treeItems.map(value => createTreeNode(value, new Counter())) - return ( -
- { - setDialogDevice(device) - }} - onSave={device => { - restClient.saveDevice(device).then(value => { - if(value.status == 200) { - setShowDeviceDialog(false) - } - }) - }} - onCancel={() => setShowDeviceDialog(false)} /> - - - { - if (cm.current) { - // Update the state of the menu (enabling/disabling some menu items) - updateMenu(event.node.data as TreeItemData) - //cm.current.props.model = menu - cm.current.show(event.originalEvent); - } - }}/> -
) -} \ No newline at end of file diff --git a/plc4j/tools/ui/frontend/project/src/components/PlcConnection.tsx b/plc4j/tools/ui/frontend/project/src/components/PlcConnection.tsx deleted file mode 100644 index 5ea8e829e..000000000 --- a/plc4j/tools/ui/frontend/project/src/components/PlcConnection.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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. - */ - -import 'primeicons/primeicons.css'; -import {TabPanel} from "primereact/tabview"; -import {TreeTable} from "primereact/treetable"; -import {Column} from "primereact/column"; - -type PlcConnectionProps = { - connectionString: string; -} - -export default function PlcConnection({connectionString}: PlcConnectionProps) { - return ( - - - - - - - - - - - - - - ) -} \ No newline at end of file diff --git a/plc4j/tools/ui/frontend/project/src/generated/plc4j-tools-ui-frontend.ts b/plc4j/tools/ui/frontend/project/src/generated/plc4j-tools-ui-frontend.ts deleted file mode 100644 index 823a36d35..000000000 --- a/plc4j/tools/ui/frontend/project/src/generated/plc4j-tools-ui-frontend.ts +++ /dev/null @@ -1,209 +0,0 @@ -/*- - * #%L - * PLC4J: Tools: Frontend - * %% - * Copyright (C) 2017 - 2024 The Apache Software Foundation - * %% - * 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. - * #L% - */ -/* tslint:disable */ -/* eslint-disable */ - -export interface Device { - id: number; - name: string; - protocolCode: string; - transportCode: string; - transportUrl: string; - options: { [index: string]: string }; - attributes: { [index: string]: string }; -} - -export interface Driver { - code: string; - name: string; - metadata: PlcDriverMetadata; -} - -export interface UiApplicationEvent extends ApplicationEvent { - source: T; - eventType: EventType; -} - -export interface DeviceEvent extends UiApplicationEvent { - source: Device; -} - -export interface PlcDriverMetadata { - discoverySupported: boolean; - protocolConfigurationOptionMetadata?: OptionMetadata; - defaultTransportCode?: string; - supportedTransportCodes: string[]; -} - -export interface ApplicationEvent extends EventObject { - timestamp: number; -} - -export interface OptionMetadata { - options: Option[]; - requiredOptions: Option[]; -} - -export interface EventObject extends Serializable { - source: any; -} - -export interface Option { - key: string; - type: OptionType; - defaultValue?: any; - required: boolean; - description: string; - since?: string; -} - -export interface Serializable { -} - -export interface HttpClient { - - request(requestConfig: { method: string; url: string; queryParams?: any; data?: any; copyFn?: (data: R) => R; options?: O; }): RestResponse; -} - -export class RestApplicationClient { - - constructor(protected httpClient: HttpClient) { - } - - /** - * HTTP DELETE /api/devices - * Java method: org.apache.plc4x.java.tools.ui.controller.DeviceController.deleteDevice - */ - deleteDevice(arg0: Device, options?: O): RestResponse { - return this.httpClient.request({ method: "DELETE", url: uriEncoding`api/devices`, data: arg0, options: options }); - } - - /** - * HTTP GET /api/devices - * Java method: org.apache.plc4x.java.tools.ui.controller.DeviceController.getAllDevices - */ - getAllDevices(options?: O): RestResponse { - return this.httpClient.request({ method: "GET", url: uriEncoding`api/devices`, options: options }); - } - - /** - * HTTP POST /api/devices - * Java method: org.apache.plc4x.java.tools.ui.controller.DeviceController.saveDevice - */ - saveDevice(arg0: Device, options?: O): RestResponse { - return this.httpClient.request({ method: "POST", url: uriEncoding`api/devices`, data: arg0, options: options }); - } - - /** - * HTTP GET /api/devices/{id} - * Java method: org.apache.plc4x.java.tools.ui.controller.DeviceController.getDeviceById - */ - getDeviceById(id: string, options?: O): RestResponse { - return this.httpClient.request({ method: "GET", url: uriEncoding`api/devices/${id}`, options: options }); - } - - /** - * HTTP GET /api/discover/{protocolCode} - * Java method: org.apache.plc4x.java.tools.ui.controller.DriverController.discover - */ - discover(protocolCode: string, options?: O): RestResponse { - return this.httpClient.request({ method: "GET", url: uriEncoding`api/discover/${protocolCode}`, options: options }); - } - - /** - * HTTP GET /api/drivers - * Java method: org.apache.plc4x.java.tools.ui.controller.DriverController.getAllDrivers - */ - getAllDrivers(options?: O): RestResponse { - return this.httpClient.request({ method: "GET", url: uriEncoding`api/drivers`, options: options }); - } -} - -export type RestResponse = Promise>; - -export type EventType = "CREATED" | "UPDATED" | "DELETED"; - -export type OptionType = "BOOLEAN" | "INT" | "LONG" | "FLOAT" | "DOUBLE" | "STRING" | "STRUCT"; - -function uriEncoding(template: TemplateStringsArray, ...substitutions: any[]): string { - let result = ""; - for (let i = 0; i < substitutions.length; i++) { - result += template[i]; - result += encodeURIComponent(substitutions[i]); - } - result += template[template.length - 1]; - return result; -} - - -// Added by 'AxiosClientExtension' extension - -import axios from "axios"; -import * as Axios from "axios"; - -declare module "axios" { - export interface GenericAxiosResponse extends Axios.AxiosResponse { - data: R; - } -} - -class AxiosHttpClient implements HttpClient { - - constructor(private axios: Axios.AxiosInstance) { - } - - request(requestConfig: { method: string; url: string; queryParams?: any; data?: any; copyFn?: (data: R) => R; options?: Axios.AxiosRequestConfig; }): RestResponse { - function assign(target: any, source?: any) { - if (source != undefined) { - for (const key in source) { - if (source.hasOwnProperty(key)) { - target[key] = source[key]; - } - } - } - return target; - } - - const config: Axios.AxiosRequestConfig = {}; - config.method = requestConfig.method as typeof config.method; // `string` in axios 0.16.0, `Method` in axios 0.19.0 - config.url = requestConfig.url; - config.params = requestConfig.queryParams; - config.data = requestConfig.data; - assign(config, requestConfig.options); - const copyFn = requestConfig.copyFn; - - const axiosResponse = this.axios.request(config); - return axiosResponse.then(axiosResponse => { - if (copyFn && axiosResponse.data) { - (axiosResponse as any).originalData = axiosResponse.data; - axiosResponse.data = copyFn(axiosResponse.data); - } - return axiosResponse; - }); - } -} - -export class AxiosRestApplicationClient extends RestApplicationClient { - - constructor(baseURL: string, axiosInstance: Axios.AxiosInstance = axios.create()) { - axiosInstance.defaults.baseURL = baseURL; - super(new AxiosHttpClient(axiosInstance)); - } -} diff --git a/plc4j/tools/ui/frontend/project/src/index.css b/plc4j/tools/ui/frontend/project/src/index.css deleted file mode 100644 index 606f99415..000000000 --- a/plc4j/tools/ui/frontend/project/src/index.css +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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. - */ - -#root { - height:100%; - width:100%!important; - background-color: gray; -} - -:root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} - -a:hover { - color: #535bf2; -} - -html { - height: 100%; -} - -body { - margin: 0; - display: flex; - place-items: center; - height:100% -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} - -button:hover { - border-color: #646cff; -} - -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - - a:hover { - color: #747bff; - } - - button { - background-color: #f9f9f9; - } -} - diff --git a/plc4j/tools/ui/frontend/project/src/layouts/MainLayout.tsx b/plc4j/tools/ui/frontend/project/src/layouts/MainLayout.tsx deleted file mode 100644 index 59786cbc5..000000000 --- a/plc4j/tools/ui/frontend/project/src/layouts/MainLayout.tsx +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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. - */ -import {Menubar} from "primereact/menubar"; -import {MenuItem} from "primereact/menuitem"; -import plc4xLogo from "../assets/plc4x-logo.svg"; -import {Image} from "primereact/image"; -import {Outlet, useNavigate} from "react-router-dom"; - -export default function MainLayout() { - const navigate = useNavigate(); - - const menuItems = [ - { - label: 'Inspect', - command() { - navigate('/inspect'); - } - }, - { - label: 'OPC-UA Server', - command() { - navigate('/opcua'); - } - }, - { - label: 'MQTT Emitter', - command() { - navigate('/mqtt'); - } - }, - { - label: 'Settings', - command() { - navigate('/settings'); - } - }, - { - label: 'About', - command() { - navigate('/about'); - } - } - ] as MenuItem[]; - - const startLogo = ; - - return ( - <> - -
- -
- - ) -} \ No newline at end of file diff --git a/plc4j/tools/ui/frontend/project/src/main.tsx b/plc4j/tools/ui/frontend/project/src/main.tsx deleted file mode 100644 index b9783a977..000000000 --- a/plc4j/tools/ui/frontend/project/src/main.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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. - */ - -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.tsx' -import './index.css' -import 'primereact/resources/themes/lara-light-indigo/theme.css'; -import 'primeicons/primeicons.css'; -import 'primereact/resources/primereact.css'; -import 'primeflex/primeflex.css' -import { Provider } from "react-redux"; -import store from "./store"; - -ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - , -) diff --git a/plc4j/tools/ui/frontend/project/src/model/TreeItemData.ts b/plc4j/tools/ui/frontend/project/src/model/TreeItemData.ts deleted file mode 100644 index 4a661ff42..000000000 --- a/plc4j/tools/ui/frontend/project/src/model/TreeItemData.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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. - */ - -import {Device, Driver} from "../generated/plc4j-tools-ui-frontend.ts"; - -export type TreeItemType = "ROOT" | "DRIVER" | "CONNECTION" | "DEVICE"; - -export interface TreeItemData { - id: string, - name: string, - type: TreeItemType, - driver?: Driver, - device?: Device, - supportsDiscovery: boolean, - supportsBrowsing: boolean, - supportsReading: boolean, - supportsWriting: boolean, - supportsSubscribing: boolean, - supportsPublishing: boolean, - children?: readonly TreeItemData[] -} diff --git a/plc4j/tools/ui/frontend/project/src/pages/About.tsx b/plc4j/tools/ui/frontend/project/src/pages/About.tsx deleted file mode 100644 index 5fc915750..000000000 --- a/plc4j/tools/ui/frontend/project/src/pages/About.tsx +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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. - */ - -export default function Inspect() { - return ( -

Settings

- ) -} diff --git a/plc4j/tools/ui/frontend/project/src/pages/Inspect.tsx b/plc4j/tools/ui/frontend/project/src/pages/Inspect.tsx deleted file mode 100644 index ab1de3100..000000000 --- a/plc4j/tools/ui/frontend/project/src/pages/Inspect.tsx +++ /dev/null @@ -1,140 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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. - */ -import {TabPanel, TabView} from "primereact/tabview"; -import {Splitter, SplitterPanel} from "primereact/splitter"; -import {ScrollPanel} from "primereact/scrollpanel"; -import NavigationTree from "../components/NavigationTree.tsx"; -import PlcConnection from "../components/PlcConnection.tsx"; -import { useSelector } from "react-redux"; -import {Device, Driver} from "../generated/plc4j-tools-ui-frontend.ts"; -import {TreeItemData} from "../model/TreeItemData.ts"; -import {RootState} from "../store"; - -function getByDriverTree(driverList: Driver[], deviceList: Device[]):TreeItemData[] { - console.log("getByDriverTree " + JSON.stringify(driverList) + " " + JSON.stringify(deviceList)) - if(driverList && deviceList) { - const driverMap = new Map() - let drivers:TreeItemData[] = [] - driverList.forEach(value => { - const driverEntry:TreeItemData = { - id: value.code, - name: value.name, - type: "DRIVER", - driver: value, - supportsDiscovery: value.metadata.discoverySupported, - supportsBrowsing: false, - supportsReading: false, - supportsWriting: false, - supportsSubscribing: false, - supportsPublishing: false, - children: [] - } - driverMap.set(value.code, driverEntry) - drivers = [...drivers, driverEntry] - }) - drivers.sort((a,b) => (a.name > b.name) ? 1 : ((b.name > a.name) ? -1 : 0)) - - deviceList.forEach(value => { - const curDriverTreeItem = driverMap.get(value.protocolCode); - if(curDriverTreeItem && curDriverTreeItem.children) { - curDriverTreeItem.children = [...curDriverTreeItem.children, { - id: value.id?.toString(), - name: value.name, - type: "DEVICE", - device: value, - // Even if most connections will support reading/writing, in order to really know, we need a - // connection first. As we're not offering any connection-actions here anyway we just set all - // to false. - supportsDiscovery: false, - supportsBrowsing: false, - supportsReading: false, - supportsWriting: false, - supportsSubscribing: false, - supportsPublishing: false} as TreeItemData - ] - } - }) - - return [{ - id: "all", - name: "Root", - type: "ROOT", - supportsDiscovery: true, - supportsBrowsing: false, - supportsReading: false, - supportsWriting: false, - supportsSubscribing: false, - supportsPublishing: false, - children: drivers - }] - } - return [] -} - -function getByDeviceTree(deviceList: Device[]):TreeItemData[] { - console.log("getByDeviceTree " + JSON.stringify(deviceList)) - if(deviceList) { - // Group the connections by transport-url. - // TODO: Possibly create filters for the different types of urls (IP, Hostname, Port, Mac-Address, ...) - const deviceMap = new Map - deviceList.forEach(device => { - const devices = deviceMap.get(device.transportUrl) - if(devices) { - deviceMap.set(device.transportUrl, [...devices, device]) - } else { - deviceMap.set(device.transportUrl, [device]) - } - }) - - // Build a tree based on the grouped locations. - } - return [] -} - -export default function Inspect() { - const lists = useSelector((state: RootState) => { - console.log("Updated connections " + JSON.stringify(state.connections)) - return state.connections - }) - return ( - - - - - - - - - - - - - - - - - - - - - - ) -} diff --git a/plc4j/tools/ui/frontend/project/src/pages/Settings.tsx b/plc4j/tools/ui/frontend/project/src/pages/Settings.tsx deleted file mode 100644 index 5fc915750..000000000 --- a/plc4j/tools/ui/frontend/project/src/pages/Settings.tsx +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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. - */ - -export default function Inspect() { - return ( -

Settings

- ) -} diff --git a/plc4j/tools/ui/frontend/project/src/store/index.ts b/plc4j/tools/ui/frontend/project/src/store/index.ts deleted file mode 100644 index 466e3eb77..000000000 --- a/plc4j/tools/ui/frontend/project/src/store/index.ts +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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. - */ - -import {configureStore, createSlice, PayloadAction} from '@reduxjs/toolkit' -import {Device, Driver} from "../generated/plc4j-tools-ui-frontend.ts"; -import {useDispatch} from "react-redux"; - -export type InitializeConnectionsAction = { - driverList: Driver[] - deviceList: Device[] -} - -export type DeviceAction = { - device: Device -} - -export interface ConnectionsState { - driverList: Driver[] - deviceList: Device[] -} - -const connectionsInitialState: ConnectionsState = { - driverList: [] as Driver[], - deviceList: [] as Device[], -} - -const connectionsSlice = createSlice({ - name: 'connections', - initialState: connectionsInitialState, - reducers: { - initializeLists: (state, action: PayloadAction) => { - state.driverList = action.payload.driverList - state.deviceList = action.payload.deviceList - }, - addDevice: (state, action: PayloadAction) => { - console.log("ADD " + JSON.stringify(action)) - state.deviceList = [...state.deviceList, action.payload.device] - }, - updateDevice: (state, action: PayloadAction) => { - console.log("UPDATE " + JSON.stringify(action)) - const device = state.deviceList.find(value => value.id == action.payload.device.id); - if (device) { - const index = state.deviceList.indexOf(device); - if (index) { - state.deviceList.splice(index, 1, action.payload.device); - } - } - }, - deleteDevice: (state, action: PayloadAction) => { - console.log("DELETE " + JSON.stringify(action)) - const device = state.deviceList.find(value => value.id == action.payload.device.id); - if (device) { - const index = state.deviceList.indexOf(device); - if (index) { - state.deviceList.splice(index, 1); - } - } - } - } -}) - -export const { initializeLists, addDevice, updateDevice, deleteDevice } = connectionsSlice.actions - -const store = configureStore({ - reducer: { - connections: connectionsSlice.reducer - } -}) -export default store - -// Infer the `RootState` and `AppDispatch` types from the store itself -export type RootState = ReturnType -// Inferred type: {connections: ConnectionsState} -export type AppDispatch = typeof store.dispatch -export const useAppDispatch: () => AppDispatch = useDispatch diff --git a/plc4j/tools/ui/frontend/project/tsconfig.json b/plc4j/tools/ui/frontend/project/tsconfig.json deleted file mode 100644 index a7fc6fbf2..000000000 --- a/plc4j/tools/ui/frontend/project/tsconfig.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "jsx": "react-jsx", - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true - }, - "include": ["src"], - "references": [{ "path": "./tsconfig.node.json" }] -} diff --git a/plc4j/tools/ui/frontend/project/tsconfig.node.json b/plc4j/tools/ui/frontend/project/tsconfig.node.json deleted file mode 100644 index 42872c59f..000000000 --- a/plc4j/tools/ui/frontend/project/tsconfig.node.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "skipLibCheck": true, - "module": "ESNext", - "moduleResolution": "bundler", - "allowSyntheticDefaultImports": true - }, - "include": ["vite.config.ts"] -} diff --git a/plc4j/tools/ui/frontend/project/vite.config.ts b/plc4j/tools/ui/frontend/project/vite.config.ts deleted file mode 100644 index d81562b46..000000000 --- a/plc4j/tools/ui/frontend/project/vite.config.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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. - */ - -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' - -// https://vitejs.dev/config/ -export default defineConfig({ - plugins: [react()], -}) diff --git a/plc4j/tools/ui/plc4j-tools-ui.iml b/plc4j/tools/ui/plc4j-tools-ui.iml deleted file mode 100644 index 30466db07..000000000 --- a/plc4j/tools/ui/plc4j-tools-ui.iml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/plc4j/tools/ui/pom.xml b/plc4j/tools/ui/pom.xml deleted file mode 100644 index 9a9296c8a..000000000 --- a/plc4j/tools/ui/pom.xml +++ /dev/null @@ -1,120 +0,0 @@ - - - - - 4.0.0 - - - org.apache.plc4x - plc4j-tools - 0.13.0-SNAPSHOT - - - plc4j-tools-ui - pom - - PLC4J: Tools: UI - Parent for the UI project, bundling the backend and the frontend part together. - - - 2024-02-16T14:53:02Z - 3.2.5 - - 10.17.1.0 - - - - application - backend - frontend - - - - - - org.apache.maven.plugins - maven-enforcer-plugin - - - - enforce-java-compatability - - enforce - - - - - 19 - - - - - - - - org.codehaus.mojo - extra-enforcer-rules - 1.8.0 - - - - - - - - - - org.springframework.boot - spring-boot-dependencies - ${spring-boot.version} - pom - import - - - - org.apache.derby - derby - ${derby.version} - - - org.apache.derby - derbytools - ${derby.version} - - - jakarta.annotation - jakarta.annotation-api - 3.0.0 - - - org.aspectj - aspectjweaver - 1.9.22 - - - - - \ No newline at end of file diff --git a/pom.xml b/pom.xml index d9c8d369d..9a9290461 100644 --- a/pom.xml +++ b/pom.xml @@ -17,19 +17,18 @@ specific language governing permissions and limitations under the License. --> - - - 4.0.0 + org.apache apache - 32 + 39 + - org.apache.plc4x + org.apache.plc4x.extras plc4x-extras-parent - 0.13.0-SNAPSHOT + 1.0.0-SNAPSHOT pom PLC4X-Extras @@ -43,8 +42,8 @@ 2017 - scm:git:https://gitbox.apache.org/repos/asf/plc4x-extras.git - scm:git:https://gitbox.apache.org/repos/asf/plc4x-extras.git + scm:git:https://github.com/apache/plc4x-extras.git + scm:git:https://github.com/apache/plc4x-extras.git https://github.com/apache/plc4x-extras HEAD @@ -79,18 +78,25 @@ - 11 + 21 UTF-8 UTF-8 yyyy-MM-dd'T'HH:mm:ss'Z' - 2024-02-16T14:53:02Z + 2026-09-20T15:22:10Z - ${project.version} + 1.0.0 + + + @@ -115,6 +121,16 @@ **/cmake-*/** **/.lock/** + + **/UNLICENSE + **/CHANGELOG.md + **/EPL-2.0 + + + **/Expression.g4 + **/target/** target/** @@ -137,6 +153,34 @@ **/*.iml **/.attach_pid* + + **/*.pcap + **/*.pcapng + + + **/.java-version + + + **/.asciidoctor/** + + + .repository/** + local-snapshots-dir/** + .local-snapshots-dir/** + + + elasticsearch-data/** + + + **/*.json + **/*.avsc + + + **/*.csv + + + **/.profiler/** + **/cmake-build-debug/** **/*.make @@ -151,6 +195,12 @@ **/CMakeCache.txt **/DartConfiguration.tcl + + plc4net/plc4net.sln.DotSettings.user + + + **/obj/** + @@ -169,18 +219,16 @@ **/go.sum + + **/tools.sum - ui/frontend/project/dist/** ui/frontend/project/node/** ui/frontend/project/node_modules/** + website/node_modules/** + .mvn/** @@ -190,6 +238,15 @@ **/project/node/** **/derby.log + + + + **/*.xsd + **/*.axml + **/*.form + **/*.mf + **/nb-configuration.xml + **/*.bob
@@ -220,10 +277,19 @@ + + org.apache.maven.plugins + maven-assembly-plugin + 3.8.0 + + apache-plc4x-extras-${project.version} + + + org.apache.rat apache-rat-plugin - 0.16.1 + 0.18 with-c - - plc4c - + + plc4c + with-go - - plc4go - + + plc4go + with-java - - plc4j - + + plc4j + @@ -490,7 +556,7 @@ ${project.build.directory} - apache-plc4x-${project.version}-source-release.zip + apache-plc4x-extras-${project.version}-source-release.zip @@ -508,9 +574,9 @@ sign - + @@ -524,9 +590,9 @@ deploy - + @@ -536,11 +602,11 @@ org.cyclonedx cyclonedx-maven-plugin - 2.8.0 + 2.9.3 false - apache-${project.artifactId}-${project.version}-sbom + apache-plc4x-extras-${project.version}-sbom diff --git a/tools/Dockerfile b/tools/Dockerfile new file mode 100644 index 000000000..d44964384 --- /dev/null +++ b/tools/Dockerfile @@ -0,0 +1,43 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You 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. + +########################################################################################## +# Build PLC4X +########################################################################################## + +# This is the image we'll use to execute the build (and give it the name 'build'). +# (This image is based on Ubuntu) +# Fixed version of this in order to have a fixed JDK version +FROM azul/zulu-openjdk:21 as build + +# Install some stuff we need to run the build +RUN apt update -y + +# Install general purpose tools +RUN apt install -y make libpcap-dev libc-dev git + +# Required for "with-c" profile +RUN apt install -y build-essential + +# Required for running on Windows systems +RUN apt install -y dos2unix + +# The release scripts can run this image as the calling user, so the exception for the +# bind-mounted checkout has to live in the system config: a "--global" one lands in +# "/root/.gitconfig", which that user never reads, and git then refuses to touch "/ws". +RUN git config --system --add safe.directory /ws + +# Change the working directory (where commands are executed) into the new "ws" directory +WORKDIR /ws \ No newline at end of file diff --git a/tools/docker-compose.yaml b/tools/docker-compose.yaml new file mode 100644 index 000000000..455de288f --- /dev/null +++ b/tools/docker-compose.yaml @@ -0,0 +1,42 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# + +services: + releaser: + build: + context: .. + dockerfile: ./tools/Dockerfile + # Builds all modules and uses an alternate maven local repo and deploys artifacts to a local directory. + command: ["/ws/mvnw", "-e", "-Dskip-pgp-signing=true", "-P", "with-c,with-go,with-java,apache-release", "-Dmaven.repo.local=/ws/out/.repository", "-DaltDeploymentRepository=snapshot-repo::file:/ws/out/.local-snapshots-dir", "clean", "deploy"] + # The build writes into the bind mount below. Running as root, everything it creates - "out", + # every "target", the commits the release plugin makes - stays owned by root once the container + # is gone. That only hurts on Linux: Docker Desktop on macOS and Windows remaps the ownership to + # the calling user anyway. The release scripts export RELEASE_UID/RELEASE_GID on Linux, and the + # "0:0" default below keeps the previous behaviour everywhere else. + user: "${RELEASE_UID:-0}:${RELEASE_GID:-0}" + environment: + # That uid has no entry in the image's "/etc/passwd", so HOME would fall back to "/", which is + # not writable - and "mvnw" unpacks Maven below HOME. Inside the mount it is writable for the + # same user and survives between runs. + - HOME=/ws/out/home + volumes: + # Bind the local directory as "/ws" + - type: bind + source: .. + target: /ws diff --git a/tools/release-0-build.sh b/tools/release-0-build.sh new file mode 100755 index 000000000..bb83571bd --- /dev/null +++ b/tools/release-0-build.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash + +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# ---------------------------------------------------------------------------- + +DIRECTORY="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# Values shared with the other release scripts (Nexus staging profile, dist.apache.org URLs) and the +# ownership settings for the docker builds. +if [[ ! -f "$DIRECTORY/tools/release-common.sh" ]]; then + echo "❌ '$DIRECTORY/tools/release-common.sh' not found, aborting." + exit 1 +fi +# shellcheck source=release-common.sh +source "$DIRECTORY/tools/release-common.sh" + +######################################################################################################################## +# 0. Check Docker Memory Availability +######################################################################################################################## + +# Minimum required memory in bytes (4 GB) +REQUIRED_MEM=$((4 * 1024 * 1024 * 1024)) + +# Extract total memory from `docker system info` +TOTAL_MEM=$(docker system info --format '{{.MemTotal}}') + +# Check if TOTAL_MEM was retrieved successfully +if [[ -z "$TOTAL_MEM" || "$TOTAL_MEM" -eq 0 ]]; then + echo "❌ Unable to determine total Docker memory. Is Docker running?" + exit 1 +fi + +# Compare and exit if not enough memory +if (( TOTAL_MEM < REQUIRED_MEM )); then + echo "❌ Docker runtime has insufficient memory: $(awk "BEGIN {printf \"%.2f\", $TOTAL_MEM/1024/1024/1024}") GB" + echo " At least 4 GB is required. Aborting." + exit 1 +fi + +######################################################################################################################## +# 1. Check if there are uncommitted changes as these would automatically be committed (local) +######################################################################################################################## + +if [[ $(git -C "$DIRECTORY" status --porcelain) ]]; then + # Changes + echo "❌ There are untracked files or changed files, aborting." + exit 1 +fi + +######################################################################################################################## +# 2. Delete the pre-exising "out" directory that contains the maven local repo and deployments (local) +######################################################################################################################## + +echo "Deleting the maven local repo and previous deployments" +rm -r "$DIRECTORY/out" + +######################################################################################################################## +# 3. Make sure the NOTICE file has the current year in the second line +######################################################################################################################## + +NOTICE_FILE="$DIRECTORY/NOTICE" +CURRENT_YEAR=$(date +%Y) +EXPECTED="Copyright 2017-${CURRENT_YEAR} The Apache Software Foundation" + +# Extract the second line +SECOND_LINE=$(sed -n '2p' "$NOTICE_FILE") + +if [[ "$SECOND_LINE" != "$EXPECTED" ]]; then + echo "âœī¸ Updating $NOTICE_FILE" + + # Replace line 2 with the expected text + awk -v expected="$EXPECTED" 'NR==2 {$0=expected} {print}' "$NOTICE_FILE" > "$NOTICE_FILE.tmp" && + mv "$NOTICE_FILE.tmp" "$NOTICE_FILE" +else + echo "✅ $NOTICE_FILE is already up to date." +fi + +######################################################################################################################## +# 4 Run the maven build for all modules with enabled all "with-*" profiles enable (Docker container) +######################################################################################################################## + +# Build the container we'll use for releasing. +if ! docker compose -f "$DIRECTORY/tools/docker-compose.yaml" build; then + echo "❌ Got non-0 exit code from building the release docker container, aborting." + exit 1 +else + echo "✅ Docker container successfully built." +fi + +# Run the main build. +if ! docker compose -f "$DIRECTORY/tools/docker-compose.yaml" run releaser \ + bash -c "/ws/mvnw -e -P with-c,with-go,with-java -Dmaven.repo.local=/ws/out/.repository clean install -DskipTests"; then + echo "❌ Got non-0 exit code from running the build inside docker, aborting." + exit 1 +else + echo "✅ Main repository compiled successfully." +fi + + +echo "✅ Pre-release updates complete. Please continue with 'release-1-create-branch.sh' next." diff --git a/tools/release-1-create-branch.sh b/tools/release-1-create-branch.sh new file mode 100755 index 000000000..03b75d57f --- /dev/null +++ b/tools/release-1-create-branch.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env bash + +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# ---------------------------------------------------------------------------- + +DIRECTORY="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# Values shared with the other release scripts (Nexus staging profile, dist.apache.org URLs) and the +# ownership settings for the docker builds. +if [[ ! -f "$DIRECTORY/tools/release-common.sh" ]]; then + echo "❌ '$DIRECTORY/tools/release-common.sh' not found, aborting." + exit 1 +fi +# shellcheck source=release-common.sh +source "$DIRECTORY/tools/release-common.sh" + + +# BSD and GNU sed disagree about "-i": BSD wants a backup suffix as a separate argument, GNU +# treats that argument as the expression. Editing through a temporary file works on both, and +# leaves nothing behind if the edit fails - a stray backup file would otherwise trip the check +# for a clean working tree the next time one of these scripts runs. +# $1 the file to edit, the rest are passed to sed unchanged +sed_in_place() { + local file="$1"; shift + if ! sed "$@" "$file" > "$file.sed.tmp"; then + rm -f "$file.sed.tmp" + return 1 + fi + mv "$file.sed.tmp" "$file" +} + +######################################################################################################################## +# 0. Check if there are uncommitted changes as these would automatically be committed (local) +######################################################################################################################## + +if [[ $(git -C "$DIRECTORY" status --porcelain) ]]; then + # Changes + echo "❌ There are untracked files or changed files, aborting." + exit 1 +fi + +# The release branch and the release tag belong in the Apache repository, which is not necessarily +# the remote called "origin" - see "resolve_apache_remote" in "release-common.sh". +require_apache_remote +echo "Apache remote: $APACHE_REMOTE ($APACHE_REMOTE_URL)" + +######################################################################################################################## +# 1. Get and calculate the current version (local) +######################################################################################################################## + +# Maven 4 prefixes even quiet output with "[INFO] [stdout] ", so take the last token of the +# last line rather than the whole output. +PROJECT_VERSION=$("$DIRECTORY/mvnw" -f "$DIRECTORY/pom.xml" -q --non-recursive -Dexpression=project.version -DforceStdout help:evaluate | tail -n 1 | awk '{print $NF}') +if [[ ! "$PROJECT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-SNAPSHOT)?$ ]]; then + echo "❌ Could not read a usable project version, got '$PROJECT_VERSION'." + echo " Everything below derives the branch and tag names from it, so aborting." + exit 1 +fi +RELEASE_VERSION=${PROJECT_VERSION%"-SNAPSHOT"} +RELEASE_SHORT_VERSION=${RELEASE_VERSION%".0"} +BRANCH_NAME="rel/$RELEASE_SHORT_VERSION" +IFS='.' read -ra VERSION_SEGMENTS <<< "$RELEASE_VERSION" +NEW_VERSION="${VERSION_SEGMENTS[0]}.$((VERSION_SEGMENTS[1] + 1)).0-SNAPSHOT" +echo "Current Version: '$PROJECT_VERSION'" +echo "Release Version: '$RELEASE_VERSION'" +echo "Release Branch Name: '$BRANCH_NAME'" +echo "New develop Version: '$NEW_VERSION'" + +######################################################################################################################## +# 3. Ask if the RELEASE_NOTES have been filled out at all (local) +######################################################################################################################## + +read -r -p "Have the RELEASE_NOTES been updated for this version? (yes/no) " yn +case $yn in + yes ) echo continuing with the process;; + no ) echo Please update the RELEASE_NOTES first; + exit 1;; + * ) echo invalid response; + exit 1;; +esac + +######################################################################################################################## +# 4 Remove the "(Unreleased)" prefix from the current version of the RELEASE_NOTES file (local) +######################################################################################################################## + +if ! sed_in_place "$DIRECTORY/RELEASE_NOTES" "s/(Unreleased) Apache PLC4X Extras $PROJECT_VERSION*/Apache PLC4X Extras $RELEASE_VERSION/"; then + echo "❌ Got non-0 exit code from updating RELEASE_NOTES, aborting." + exit 1 +fi + +# Commit this change to git. +git -C "$DIRECTORY" add --all +git -C "$DIRECTORY" commit -m "chore: updated generated code" + +######################################################################################################################## +# 5. Do a simple maven branch command with pushChanges=false +######################################################################################################################## + +# Attempt to read user.name and user.email (local first, then global) +GIT_USER_NAME=$(git config user.name || git config --global user.name) +GIT_USER_EMAIL=$(git config user.email || git config --global user.email) + +# Check if either is still unset +if [[ -z "$GIT_USER_NAME" || -z "$GIT_USER_EMAIL" ]]; then + echo "❌ Git user.name and/or user.email not configured." + echo + echo "Please run one of the following commands:" + echo " git config --global user.name \"Your Name\"" + echo " git config --global user.email \"you@example.com\"" + echo + echo "Or configure them just for this repository:" + echo " git config user.name \"Your Name\"" + echo " git config user.email \"you@example.com\"" + exit 1 +fi + +# The container mounts this repository, so git inside it reads the very same ".git/config". +# If that enables commit/tag signing, every commit the release plugin creates fails with +# "gpg failed to sign the data", as the container has neither the key nor a gpg-agent. Passing +# the identity and the signing switches as GIT_CONFIG_* environment variables overrides the +# config files for the container only, so nothing is written back into the user's repository. +if ! docker compose -f "$DIRECTORY/tools/docker-compose.yaml" run releaser \ + bash -c "export GIT_CONFIG_COUNT=4 \ + GIT_CONFIG_KEY_0=user.name GIT_CONFIG_VALUE_0=\"$GIT_USER_NAME\" \ + GIT_CONFIG_KEY_1=user.email GIT_CONFIG_VALUE_1=\"$GIT_USER_EMAIL\" \ + GIT_CONFIG_KEY_2=commit.gpgsign GIT_CONFIG_VALUE_2=false \ + GIT_CONFIG_KEY_3=tag.gpgsign GIT_CONFIG_VALUE_3=false && \ + /ws/mvnw -e -P with-c,with-go,with-java -Dmaven.repo.local=/ws/out/.repository release:branch -DautoVersionSubmodules=true -DpushChanges=false -DdevelopmentVersion='$NEW_VERSION' -DbranchName='$BRANCH_NAME'"; then + echo "❌ Got non-0 exit code from docker compose, aborting." + exit 1 +fi + +######################################################################################################################## +# 6. Add a new section for the new version to the RELEASE_NOTES file (local) +######################################################################################################################## + +NEW_HEADER="==============================================================\n\ +(Unreleased) Apache PLC4X Extras $NEW_VERSION\n\ +==============================================================\n\ +\n\ +New Features\n\ +------------\n\ +\n\ +Incompatible changes\n\ +--------------------\n\ +\n\ +Bug Fixes\n\ +---------\n\ +\n\ +==============================================================\ +" +echo NEW_VERSION +if ! sed_in_place "$DIRECTORY/RELEASE_NOTES" "1s/.*/$NEW_HEADER/"; then + echo "❌ Got non-0 exit code from adding a new header to RELEASE_NOTES, aborting." + exit 1 +fi + +######################################################################################################################## +# 7. Commit the change (local) +######################################################################################################################## + +if ! git -C "$DIRECTORY" add --all; then + echo "❌ Got non-0 exit code from adding all changes files, aborting." + exit 1 +fi +if ! git -C "$DIRECTORY" commit -m "chore: prepared the RELEASE_NOTES and the documentation version for the next version."; then + echo "❌ Got non-0 exit code from committing changes files, aborting." + exit 1 +fi + +######################################################################################################################## +# 8. Push the changes (local) +######################################################################################################################## + +if ! git -C "$DIRECTORY" push; then + echo "❌ Got non-0 exit code from pushing changes, aborting." + exit 1 +fi + +######################################################################################################################## +# 9. Switch to the release branch (local) +######################################################################################################################## + +if ! git -C "$DIRECTORY" checkout "$BRANCH_NAME"; then + echo "❌ Got non-0 exit code from switching branches to the release branch, aborting." + exit 1 +fi + + +# Make sure the release branch is also pushed to the remote. +if ! git -C "$DIRECTORY" push --set-upstream "$APACHE_REMOTE" "$BRANCH_NAME"; then + echo "❌ Got non-0 exit code from pushing changes, aborting." + exit 1 +fi + +echo "✅ Release branch creation complete. We have switched the local branch to the release branch. Please continue with 'release-2-prepare-release.sh' as soon as the release branch is ready for being released." diff --git a/tools/release-2-prepare-release.sh b/tools/release-2-prepare-release.sh new file mode 100755 index 000000000..1c92d2866 --- /dev/null +++ b/tools/release-2-prepare-release.sh @@ -0,0 +1,389 @@ +#!/usr/bin/env bash + +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# ---------------------------------------------------------------------------- + +DIRECTORY="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# Values shared with the other release scripts (Nexus staging profile, dist.apache.org URLs). +if [[ ! -f "$DIRECTORY/tools/release-common.sh" ]]; then + echo "❌ '$DIRECTORY/tools/release-common.sh' not found, aborting." + exit 1 +fi +# shellcheck source=release-common.sh +source "$DIRECTORY/tools/release-common.sh" + + +######################################################################################################################## +# 0. Check if there are uncommitted changes as these would automatically be committed (local) +######################################################################################################################## + +if [[ $(git -C "$DIRECTORY" status --porcelain) ]]; then + # Changes + echo "❌ There are untracked files or changed files, aborting." + exit 1 +fi + +# The release branch and the release tag belong in the Apache repository, which is not necessarily +# the remote called "origin" - see "resolve_apache_remote" in "release-common.sh". +require_apache_remote +echo "Apache remote: $APACHE_REMOTE ($APACHE_REMOTE_URL)" + +# Maven 4 prefixes even quiet output with "[INFO] [stdout] ", so take the last token of the +# last line rather than the whole output. +PROJECT_VERSION=$("$DIRECTORY"/mvnw -f "$DIRECTORY"/pom.xml -q --non-recursive -Dexpression=project.version -DforceStdout help:evaluate | tail -n 1 | awk '{print $NF}') +if [[ ! "$PROJECT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-SNAPSHOT)?$ ]]; then + echo "❌ Could not read a usable project version, got '$PROJECT_VERSION'." + echo " Everything below derives the branch and tag names from it, so aborting." + exit 1 +fi +RELEASE_VERSION=${PROJECT_VERSION%"-SNAPSHOT"} +TAG_NAME="v$RELEASE_VERSION" +IFS='.' read -ra VERSION_SEGMENTS <<< "$RELEASE_VERSION" +NEW_VERSION="${VERSION_SEGMENTS[0]}.${VERSION_SEGMENTS[1]}.$((VERSION_SEGMENTS[2] + 1))-SNAPSHOT" + +# Check if a local tag already exists (This can happen if a first release attempt failed) +if git -C "$DIRECTORY" rev-parse "$TAG_NAME" >/dev/null 2>&1; then + echo "❌ Tag '$TAG_NAME' exists locally. Please delete with 'git tag -d $TAG_NAME'" + exit 1 +else + echo "✅ Tag '$TAG_NAME' does not exist locally." +fi + +# Check if a remote tag already exists (This can happen if a first release attempt failed) +if git -C "$DIRECTORY" ls-remote --tags "$APACHE_REMOTE" | grep -q "refs/tags/$TAG_NAME$"; then + echo "❌ Tag '$TAG_NAME' exists on remote '$APACHE_REMOTE'. Please delete with 'git push $APACHE_REMOTE --delete $TAG_NAME'" + exit 1 +else + echo "✅ Tag '$TAG_NAME' does not exist on remote '$APACHE_REMOTE'." +fi + +######################################################################################################################## +# 1. Do a simple release-prepare command +######################################################################################################################## + +# Attempt to read user.name and user.email (local first, then global) +GIT_USER_NAME=$(git config user.name || git config --global user.name) +GIT_USER_EMAIL=$(git config user.email || git config --global user.email) + +if [[ -z "$GIT_USER_NAME" || -z "$GIT_USER_EMAIL" ]]; then + echo "❌ Git user.name and/or user.email not configured." + echo + echo "Please run one of the following commands:" + echo " git config --global user.name \"Your Name\"" + echo " git config --global user.email \"you@example.com\"" + echo + echo "Or configure them just for this repository:" + echo " git config user.name \"Your Name\"" + echo " git config user.email \"you@example.com\"" + exit 1 +fi + +# The container mounts this repository, so git inside it reads the very same ".git/config". +# If that enables commit/tag signing, every commit and tag the release plugin creates fails with +# "gpg failed to sign the data", as the container has neither the key nor a gpg-agent. Passing +# the identity and the signing switches as GIT_CONFIG_* environment variables overrides the +# config files for the container only, so nothing is written back into the user's repository. +if ! docker compose -f "$DIRECTORY/tools/docker-compose.yaml" run releaser \ + bash -c "export GIT_CONFIG_COUNT=4 \ + GIT_CONFIG_KEY_0=user.name GIT_CONFIG_VALUE_0=\"$GIT_USER_NAME\" \ + GIT_CONFIG_KEY_1=user.email GIT_CONFIG_VALUE_1=\"$GIT_USER_EMAIL\" \ + GIT_CONFIG_KEY_2=commit.gpgsign GIT_CONFIG_VALUE_2=false \ + GIT_CONFIG_KEY_3=tag.gpgsign GIT_CONFIG_VALUE_3=false && \ + /ws/mvnw -e -P with-c,with-go,with-java -Dmaven.repo.local=/ws/out/.repository release:prepare -DautoVersionSubmodules=true -DpushChanges=false -DreleaseVersion='$RELEASE_VERSION' -DdevelopmentVersion='$NEW_VERSION' -Dtag='$TAG_NAME'"; then + echo "❌ Got non-0 exit code from docker compose, aborting." + exit 1 +fi + +######################################################################################################################## +# 2. Push the changes (local) +######################################################################################################################## + +# "release:prepare" ran with "-DpushChanges=false", so the release commits and the tag it created +# are still local. The container it ran in has no ssh key, no credential helper and no terminal, so +# its own push would have failed - after the full build that precedes it. Pushing from the host +# instead uses the release manager's credentials. +# +# The tag has to go with them, and it has to go now: "release:perform" below checks the tag out +# from the remote, not from this working copy. +if ! git -C "$DIRECTORY" push "$APACHE_REMOTE" HEAD; then + echo "❌ Got non-0 exit code from pushing changes to git, aborting." + exit 1 +fi + +if ! git -C "$DIRECTORY" push "$APACHE_REMOTE" "$TAG_NAME"; then + echo "❌ Got non-0 exit code from pushing the release tag to git, aborting." + echo " The commits are already pushed, so only the tag is missing:" + echo " git push $APACHE_REMOTE $TAG_NAME" + exit 1 +fi + +TAG_COMMIT_HASH=$(git -C "$DIRECTORY" rev-list -n 1 "$TAG_NAME") +echo "✅ Tag '$TAG_NAME' has hash '$TAG_COMMIT_HASH'" + +######################################################################################################################## +# 3. Do a simple release-perform command skip signing of artifacts and deploy to local directory (inside the Docker container) +######################################################################################################################## + +echo "Performing Release:" +if ! docker compose -f "$DIRECTORY/tools/docker-compose.yaml" run releaser \ + bash -c "/ws/mvnw -e -Dmaven.repo.local=/ws/out/.repository -DaltDeploymentRepository=snapshot-repo::file:/ws/out/.local-artifacts-dir release:perform"; then + echo "❌ Got non-0 exit code from docker compose, aborting." + exit 1 +fi + +######################################################################################################################## +# 4. Sign all artifacts +######################################################################################################################## + +echo "Signing artifacts:" +SIGNED_ARTIFACTS=0 +# The loop reads from a process substitution rather than from a pipe on purpose: the last stage +# of a pipeline runs in a subshell, where an "exit" would only end the loop and let the release +# carry on to the deployment below with unsigned artifacts. +while read -r line ; do + echo "Processing $line" + if ! gpg -ab "$line"; then + echo "❌ Got non-0 exit code from signing artifact, aborting." + exit 1 + fi + SIGNED_ARTIFACTS=$((SIGNED_ARTIFACTS + 1)) +done < <(find "$DIRECTORY/out/.local-artifacts-dir" -print | grep -E '^((.*\.pom)|(.*\.jar)|(.*\.kar)|(.*\.nar)|(.*-features\.xml)|(.*-cyclonedx\.json)|(.*-cyclonedx\.xml)|(.*-site\.xml)|(.*\.zip))$') + +if [[ "$SIGNED_ARTIFACTS" -eq 0 ]]; then + echo "❌ Found no artifacts to sign in '$DIRECTORY/out/.local-artifacts-dir', aborting." + exit 1 +fi +echo "✅ Signed $SIGNED_ARTIFACTS artifacts." + +######################################################################################################################## +# 5. Deploy the artifacts to Nexus and close the staging repo +######################################################################################################################## + +echo "Deploying artifacts:" +# Clean up any pre-existing properties file, as otherwise we'll also deploy that, +# and that will cause errors when closing. +rm "$DIRECTORY/out/.local-artifacts-dir/$STAGING_PROFILE_ID.properties" +if ! MAVEN_OPTS="$NEXUS_MAVEN_OPTS" "$DIRECTORY/mvnw" -f "$DIRECTORY/tools/stage.pom" nexus-staging:deploy-staged-repository -DstagingProfileId=$STAGING_PROFILE_ID; then + echo "❌ Got non-0 exit code from staging artifacts, aborting." + exit 1 +fi + +# Get the url of the closed repo to add that to the VOTE email +DEPLOY_PROPS="$DIRECTORY/out/.local-artifacts-dir/$STAGING_PROFILE_ID.properties" +STAGING_REPO_ID=$(grep stagingRepository.id "$DEPLOY_PROPS" | cut -d= -f2) +STAGING_REPO_URL="$NEXUS_URL/content/repositories/$STAGING_REPO_ID" +echo "✅ Staging repository closed: $STAGING_REPO_URL" + +######################################################################################################################## +# 6. Prepare a directory for the release candidate +######################################################################################################################## + +echo "Staging release candidate:" +read -r -p 'Release-Candidate number: ' rcNumber +RELEASE_CANDIDATE="rc$rcNumber" + +# RELEASE_VERSION is the one derived from the project version at the top of this script. It used +# to be re-derived here by listing the built artifacts, which silently produced several words - +# and with it a set of broken paths - whenever "out" still held a previous attempt. Rather than +# guess the version from whatever is lying around, check that the build we just did is there. +ARTIFACTS_DIR="$DIRECTORY/out/.local-artifacts-dir/org/apache/plc4x/plc4x-parent/$RELEASE_VERSION" +if [[ ! -d "$ARTIFACTS_DIR" ]]; then + echo "❌ Found no built artifacts for $RELEASE_VERSION in '$ARTIFACTS_DIR', aborting." + echo " These versions are present:" + find "$DIRECTORY/out/.local-artifacts-dir/org/apache/plc4x/plc4x-parent/" -maxdepth 1 -mindepth 1 -type d -exec basename {} \; 2>/dev/null | sed 's|^| |' + exit 1 +fi + +STAGE_DIR="$DIRECTORY/out/stage/$RELEASE_VERSION/$RELEASE_CANDIDATE" +mkdir -p "$STAGE_DIR" +cp "$DIRECTORY/README.md" "$STAGE_DIR/README" +cp "$DIRECTORY/RELEASE_NOTES" "$STAGE_DIR" +cp "$ARTIFACTS_DIR/plc4x-parent-$RELEASE_VERSION-source-release.zip" "$STAGE_DIR/apache-plc4x-$RELEASE_VERSION-source-release.zip" +cp "$ARTIFACTS_DIR/plc4x-parent-$RELEASE_VERSION-source-release.zip.asc" "$STAGE_DIR/apache-plc4x-$RELEASE_VERSION-source-release.zip.asc" +cp "$ARTIFACTS_DIR/plc4x-parent-$RELEASE_VERSION-source-release.zip.sha512" "$STAGE_DIR/apache-plc4x-$RELEASE_VERSION-source-release.zip.sha512" +cp "$ARTIFACTS_DIR/plc4x-parent-$RELEASE_VERSION-cyclonedx.json" "$STAGE_DIR/apache-plc4x-$RELEASE_VERSION-cyclonedx.json" +cp "$ARTIFACTS_DIR/plc4x-parent-$RELEASE_VERSION-cyclonedx.json.asc" "$STAGE_DIR/apache-plc4x-$RELEASE_VERSION-cyclonedx.json.asc" +cp "$ARTIFACTS_DIR/plc4x-parent-$RELEASE_VERSION-cyclonedx.xml" "$STAGE_DIR/apache-plc4x-$RELEASE_VERSION-cyclonedx.xml" +cp "$ARTIFACTS_DIR/plc4x-parent-$RELEASE_VERSION-cyclonedx.xml.asc" "$STAGE_DIR/apache-plc4x-$RELEASE_VERSION-cyclonedx.xml.asc" + +######################################################################################################################## +# 7. Make sure the currently used GPG key is available in the KEYS file +######################################################################################################################## + +ORIGINAL_FILE="$STAGE_DIR/apache-plc4x-$RELEASE_VERSION-source-release.zip" +SIGNATURE_FILE="$STAGE_DIR/apache-plc4x-$RELEASE_VERSION-source-release.zip.asc" + +# KEYS_URL comes from tools/release-common.sh +TEMP_DIR=$(mktemp -d) +KEYS_FILE="$TEMP_DIR/KEYS" +KEYRING="$TEMP_DIR/pubring.kbx" + +# Fetch KEYS file +echo "đŸ”Ŋ Downloading KEYS file from $KEYS_URL" +if ! curl -fsSL "$KEYS_URL" -o "$KEYS_FILE"; then + echo "❌ Could not download the KEYS file from $KEYS_URL, aborting." + rm -rf "$TEMP_DIR" + exit 1 +fi +if [[ ! -s "$KEYS_FILE" ]]; then + echo "❌ The KEYS file downloaded from $KEYS_URL is empty, aborting." + rm -rf "$TEMP_DIR" + exit 1 +fi + +# Import keys into temporary keyring +echo "🔑 Importing KEYS into temporary GPG keyring" +if ! gpg --no-default-keyring --keyring "$KEYRING" --import "$KEYS_FILE" > /dev/null 2>&1; then + echo "❌ Could not import the KEYS file into a temporary keyring, aborting." + rm -rf "$TEMP_DIR" + exit 1 +fi +# Without this an empty keyring would make the verification below fail, and the message would +# blame the release manager's key instead of the KEYS file that never arrived. +IMPORTED_KEYS=$(gpg --no-default-keyring --keyring "$KEYRING" --with-colons --list-keys 2>/dev/null | grep -c '^pub:') +if [[ "$IMPORTED_KEYS" -eq 0 ]]; then + echo "❌ The temporary keyring contains no keys after importing $KEYS_URL, aborting." + rm -rf "$TEMP_DIR" + exit 1 +fi +echo "🔑 Imported $IMPORTED_KEYS keys from the KEYS file" + +# Verify the signature +echo "🧾 Verifying signature on $ORIGINAL_FILE with $SIGNATURE_FILE" +if gpg --no-default-keyring --keyring "$KEYRING" --verify "$SIGNATURE_FILE" "$ORIGINAL_FILE" > /dev/null 2>&1; then + echo "✅ Signature is valid and signed by a key in the Apache PLC4X KEYS file" +else + echo "❌ Signature is invalid or the key is not in the Apache PLC4X KEYS file" + exit 1 +fi + +######################################################################################################################## +# 7b. Make sure that key belongs to an Apache identity +######################################################################################################################## + +# Being in the KEYS file is not quite enough: the key used to sign an Apache release has to carry +# the release manager's {apache-id}@apache.org address. A key can have several user ids, and only +# one of them has to be the Apache one. + +SIGNATURE_STATUS=$(gpg --no-default-keyring --keyring "$KEYRING" --status-fd 1 \ + --verify "$SIGNATURE_FILE" "$ORIGINAL_FILE" 2>/dev/null) +# Field 3 of VALIDSIG is the key that made the signature, which is the signing subkey for anyone +# who signs with one. The last field is the fingerprint of the primary key, which is what carries +# the user ids, so prefer that and fall back to field 3 for older gpg output. +SIGNING_KEY=$(echo "$SIGNATURE_STATUS" | awk '/^\[GNUPG:\] VALIDSIG /{print ($12 != "" ? $12 : $3); exit}') +if [[ -z "$SIGNING_KEY" ]]; then + echo "❌ Could not work out which key signed the release, aborting." + rm -rf "$TEMP_DIR" + exit 1 +fi + +# Field 2 is the validity of the user id: skip the ones that are revoked ("r") or expired ("e"), +# so that an @apache.org address the key no longer stands behind does not satisfy the check. +SIGNING_UIDS=$(gpg --no-default-keyring --keyring "$KEYRING" --with-colons --list-keys "$SIGNING_KEY" 2>/dev/null \ + | awk -F: '$1 == "uid" && $2 !~ /^[re]$/ {print $10}') +if echo "$SIGNING_UIDS" | grep -qiE '<[^>]+@apache\.org>'; then + echo "✅ The signing key is registered to an apache.org address" +else + echo "❌ The signing key is not registered to an apache.org address, aborting." + echo " Releases have to be signed with a key carrying your {apache-id}@apache.org address." + echo " Key $SIGNING_KEY currently carries:" + echo "$SIGNING_UIDS" | sed 's|^| |' + rm -rf "$TEMP_DIR" + exit 1 +fi + +# Cleanup +rm -rf "$TEMP_DIR" + +######################################################################################################################## +# 8. Validate the sha512 hashes +######################################################################################################################## + +ORIGINAL_FILE="$STAGE_DIR/apache-plc4x-$RELEASE_VERSION-source-release.zip" +HASHES_FILE="$STAGE_DIR/apache-plc4x-$RELEASE_VERSION-source-release.zip.sha512" + +ACTUAL_HASH=$(shasum -a 512 "$ORIGINAL_FILE" | awk '{ print $1 }') +EXPECTED_HASH=$(tr -d ' \n\r' < "$HASHES_FILE") + +if [ "$ACTUAL_HASH" = "$EXPECTED_HASH" ]; then + echo "✅ SHA-512 hash matches" +else + echo "❌ SHA-512 hash does not match" + echo "Expected: $EXPECTED_HASH" + echo "Actual: $ACTUAL_HASH" + exit 1 +fi + +######################################################################################################################## +# 9. Upload the release candidate artifacts to SVN +######################################################################################################################## + +cd "$DIRECTORY/out/stage/$RELEASE_VERSION" || exit +svn import "$RELEASE_CANDIDATE" "$DIST_DEV/$RELEASE_VERSION/$RELEASE_CANDIDATE" -m"Staging of $RELEASE_CANDIDATE of PLC4X $RELEASE_VERSION" + +######################################################################################################################## +# 10. Prepare the [VOTE] and [DISCUSS] emails +######################################################################################################################## + +cat > "$DIRECTORY/out/stage/vote-email.eml" < "$DIRECTORY/out/stage/discuss-email.eml" < "$file.sed.tmp"; then + rm -f "$file.sed.tmp" + return 1 + fi + mv "$file.sed.tmp" "$file" +} + +######################################################################################################################## +# Helpers for maintaining the download page +######################################################################################################################## + +# The download page exists on every branch that publishes documentation, and each copy has to list +# the release, because ".../plc4x/latest/users/download.html" is served from the release branch +# while ".../plc4x/pre-release/..." is served from "develop". +# +# Prints the section of the RELEASE_NOTES belonging to one version, converted to AsciiDoc. +# $1 the RELEASE_NOTES file, $2 the version +release_notes_to_asciidoc() { + awk -v ver="$2" ' + /^=+$/ { + if (state == 2) { exit } # banner of the next version, we are done + if (state == 1) { state = 2 } # closing banner of our own title + else { pending = 1 } + next + } + { + if (pending) { + pending = 0 + title = $0 + sub(/^\(Unreleased\) /, "", title) + if (title == "Apache PLC4X Extras " ver) { state = 1 } + next + } + if (state == 2) { print } + } + ' "$1" | awk ' + # Each bullet ends up on a single line. The RELEASE_NOTES wrap them and indent the + # continuations, and neither keeping nor stripping that indentation is safe: a continuation + # that happens to start with something like "18446744073709551615." would be read as an + # ordered list item by AsciiDoc. Joining the lines avoids the question and renders the same. + function flush() { if (buffer != "") { print buffer; buffer = "" } } + { lines[NR] = $0 } + END { + last = NR + while (last > 0 && lines[last] ~ /^[ \t]*$/) { last-- } # trim trailing blank lines + buffer = "" + for (i = 1; i <= last; i++) { + l = lines[i] + # "New Features" underlined with dashes becomes a level 4 heading + if (l ~ /^[A-Za-z]/ && lines[i+1] ~ /^-+$/) { flush(); print "==== " l; i++; started = 1; continue } + if (l ~ /^[ \t]*$/) { if (!started) { continue } # skip the blank lines before the first heading + flush(); print ""; continue } + started = 1 + sub(/^[ \t]+/, "", l) + if (l ~ /^[-*] /) { # a new bullet + flush() + sub(/^- PLC4X-[0-9]+ /, "- ", l) # older notes carried the issue id in front + buffer = l + } else if (buffer != "") { # a wrapped continuation of the current bullet + buffer = buffer " " l + } else { # ordinary prose + print l + } + } + flush() + } + ' +} + +# Adds a release to a download page: the entry that was current is moved down to "Previous +# Releases" with its source-release link pointed at the archive, and the new one takes its place. +# $1 the download.adoc to edit, $2 the version, $3 the RELEASE_NOTES to take the notes from +update_download_page() { + local page="$1" version="$2" notes="$3" + local anchor="release-${version//./_}" + + if [[ ! -f "$page" ]]; then + echo "❌ Download page '$page' not found, aborting." + exit 1 + fi + if grep -q "^\[#$anchor\]$" "$page"; then + echo "✅ $version is already listed on '$page'." + return 0 + fi + + local current_line previous_line + current_line=$(grep -n '^== Current Releases$' "$page" | head -1 | cut -d: -f1) + previous_line=$(grep -n '^== Previous Releases$' "$page" | head -1 | cut -d: -f1) + if [[ -z "$current_line" || -z "$previous_line" || "$previous_line" -le "$current_line" ]]; then + echo "❌ '$page' has no usable 'Current Releases'/'Previous Releases' structure, aborting." + exit 1 + fi + + # This function always makes $version the current release and pushes what was there into + # "Previous Releases". That is only right when $version really is the newest release: a bugfix + # off an old branch (0.13.2 while 1.0.0 is current) would otherwise archive 1.0.0. Where that + # happens the page needs a human decision about how to present two maintained lines. + local current_version + current_version=$(sed -n "$((current_line + 1)),$((previous_line - 1))p" "$page" \ + | grep -oE '^\[#release-[0-9_]+\]$' | head -1 | sed -E 's|^\[#release-||; s|\]$||; s|_|.|g') + if [[ -n "$current_version" && "$current_version" != "$version" ]] \ + && [[ "$(printf '%s\n%s\n' "$version" "$current_version" | sort -V | tail -1)" != "$version" ]]; then + echo "❌ '$page' lists $current_version as the current release, which is newer than $version, aborting." + echo " This looks like a bugfix release off an older branch. Add the entry by hand so that" + echo " $current_version stays the current release." + exit 1 + fi + + local notes_body + notes_body=$(release_notes_to_asciidoc "$notes" "$version") + if [[ -z "$notes_body" ]]; then + echo "❌ Found no RELEASE_NOTES section for $version in '$notes', aborting." + exit 1 + fi + + { + head -n "$current_line" "$page" + echo + echo "[#$anchor]" + echo "=== $version Official https://www.apache.org/dyn/closer.lua/plc4x/plc4x-extras/$version/apache-plc4x-extras-$version-source-release.zip[source release] [ https://downloads.apache.org/plc4x-extras/$version/apache-plc4x-extras-$version-source-release.zip.sha512[SHA512] ] [ https://downloads.apache.org/plc4x/plc4x-extras/$version/apache-plc4x-extras-$version-source-release.zip.asc[ASC] ]" + echo + echo "$notes_body" + echo + echo "== Previous Releases" + # What used to be current, with its source-release link moved to the archive. + sed -n "$((current_line + 1)),$((previous_line - 1))p" "$page" \ + | sed 's|https://www\.apache\.org/dyn/closer\.lua/plc4x/plc4x-extras/|https://archive.apache.org/dist/plc4x/plc4x-extras/|g' + tail -n +"$((previous_line + 1))" "$page" + } > "$page.tmp" + + if ! grep -q "^\[#$anchor\]$" "$page.tmp"; then + echo "❌ Could not add $version to '$page', aborting." + rm -f "$page.tmp" + exit 1 + fi + mv "$page.tmp" "$page" + echo "✅ $version added to '$page'." +} + +# ######################################################################################################################## +# # 1. Publish the documentation of this branch as the current release (local) +# ######################################################################################################################## + +# # Up to this point the release branch documented the version it was going to release, but was +# # still flagged as a prerelease, so ".../plc4x/latest/..." kept serving the previous release +# # while the vote was running (see 'release-1-create-branch.sh'). The vote has passed, so the +# # flag can be cleared. Antora then picks this branch as the latest version of the component +# # and publishes it as ".../plc4x/latest/...", which at the same time demotes the previous +# # release branch to its own version number. Nothing has to be changed on that older branch. +# # +# # The version is taken from the tag 'release-2-prepare-release.sh' created, so that this also +# # does the right thing for a bugfix release, where 'release-1-create-branch.sh' never ran. + +# ANTORA_DESCRIPTOR="$DIRECTORY/website/asciidoc/antora.yml" + +# RELEASE_TAG=$(git -C "$DIRECTORY" describe --tags --abbrev=0 --match "v*" 2>/dev/null) +# if [[ -z "$RELEASE_TAG" ]]; then +# echo "❌ Could not determine the release tag of this branch, aborting." +# echo " Are you on the release branch, and did 'release-2-prepare-release.sh' run?" +# exit 1 +# fi +# RELEASED_VERSION=${RELEASE_TAG#v} + +# # "git describe" finds the nearest tag reachable from HEAD, which on a maintenance branch that has +# # not been prepared yet is the PREVIOUS release. Check the tag actually belongs to this branch. +# TAG_LINE=$(echo "$RELEASED_VERSION" | cut -d. -f1,2) +# if [[ "$RELEASE_BRANCH" != "rel/$TAG_LINE" ]]; then +# echo "❌ The most recent tag is '$RELEASE_TAG', which does not belong to branch '$RELEASE_BRANCH', aborting." +# echo " Expected a tag on the rel/$TAG_LINE line. Has 'release-2-prepare-release.sh' run on this branch?" +# exit 1 +# fi + +# read -r -p "Did the vote for $RELEASED_VERSION pass and is it being released? (yes/no) " yn +# case $yn in +# yes ) echo "Publishing the documentation of $RELEASED_VERSION as the current release.";; +# no ) echo "Not publishing anything. Re-run this script once the vote has passed."; +# exit 1;; +# * ) echo "invalid response"; +# exit 1;; +# esac + +# if [[ ! -f "$ANTORA_DESCRIPTOR" ]]; then +# echo "❌ Antora descriptor '$ANTORA_DESCRIPTOR' not found, aborting." +# exit 1 +# fi +# # Check the RELEASE_NOTES before writing anything: the download page needs them further down, and +# # aborting halfway would leave the descriptor modified and the next run blocked by the check for a +# # clean working tree at the top of this script. +# if [[ -z "$(release_notes_to_asciidoc "$DIRECTORY/RELEASE_NOTES" "$RELEASED_VERSION")" ]]; then +# echo "❌ Found no RELEASE_NOTES section for $RELEASED_VERSION, aborting." +# echo " Expected a block headed 'Apache PLC4X Extras $RELEASED_VERSION' - if it still says" +# echo " '(Unreleased) ... -SNAPSHOT', 'release-1-create-branch.sh' never finalized it." +# exit 1 +# fi +# if ! grep -qE "^version:" "$ANTORA_DESCRIPTOR" || ! grep -qE "^prerelease:" "$ANTORA_DESCRIPTOR"; then +# echo "❌ '$ANTORA_DESCRIPTOR' has no 'version:' and/or 'prerelease:' key, aborting." +# exit 1 +# fi +# if ! sed_in_place "$ANTORA_DESCRIPTOR" -E "s|^version:.*|version: '$RELEASED_VERSION'|"; then +# echo "❌ Got non-0 exit code from updating the version in the Antora descriptor, aborting." +# exit 1 +# fi +# if ! sed_in_place "$ANTORA_DESCRIPTOR" -E "s|^prerelease:.*|prerelease: False|"; then +# echo "❌ Got non-0 exit code from clearing the prerelease flag, aborting." +# exit 1 +# fi +# echo "✅ '$ANTORA_DESCRIPTOR' now publishes $RELEASED_VERSION as the current release." + +# # The dependency snippets throughout the docs of this branch ("...", the Gradle +# # equivalents, the capture-replay jar name, ...) all render from a single Antora attribute. On +# # "develop" that attribute is kept in sync with "${project.version}" by the "sync-antora-version" +# # execution in website/pom.xml, which is correct there. It is NOT correct here: +# # "release-2-prepare-release.sh" has already moved the poms on to the next bugfix SNAPSHOT, so +# # "${project.version}" on this branch is something nobody can depend on (0.13.2-SNAPSHOT while +# # 0.13.1 is what was released). Take the value from the release tag instead, so that +# # ".../plc4x/latest/..." tells people to depend on the version that actually exists. +# # +# # Branches cut before the attribute was renamed still carry the old name, and this script has to +# # keep working for a bugfix release on one of those, so accept either spelling. +# ANTORA_VERSION_ATTRIBUTE="" +# for candidate in current-project-version current-last-released-version; do +# if grep -qE "^[[:space:]]*$candidate:" "$ANTORA_DESCRIPTOR"; then +# ANTORA_VERSION_ATTRIBUTE="$candidate" +# break +# fi +# done +# if [[ -z "$ANTORA_VERSION_ATTRIBUTE" ]]; then +# echo "❌ '$ANTORA_DESCRIPTOR' has neither a 'current-project-version' nor a legacy" +# echo " 'current-last-released-version' attribute, aborting." +# echo " Without it the documentation would advertise a version nobody can depend on." +# exit 1 +# fi +# if ! sed_in_place "$ANTORA_DESCRIPTOR" -E \ +# "s|^([[:space:]]*)$ANTORA_VERSION_ATTRIBUTE:.*|\\1$ANTORA_VERSION_ATTRIBUTE: '$RELEASED_VERSION'|"; then +# echo "❌ Got non-0 exit code from updating '$ANTORA_VERSION_ATTRIBUTE', aborting." +# exit 1 +# fi +# echo "✅ '$ANTORA_DESCRIPTOR' now advertises $RELEASED_VERSION in its dependency snippets." + +# # "current-full-version" is the same value without the "-SNAPSHOT" suffix, and it is what the +# # release and validation runbooks of this branch render their example URLs and commands from. A +# # released version never carries that suffix, so here it is simply the released version as well. +# # Without this, the attribute keeps the value "release-1-create-branch.sh" derived when the branch +# # was cut, and a second release from the same branch (0.13.1 after 0.13.0) leaves the branch +# # claiming two different versions - the snippets updated above, the runbooks one release behind. +# # Older branches may not have the attribute at all, and that is not worth aborting a finished +# # release over. +# if grep -qE "^[[:space:]]*current-full-version:" "$ANTORA_DESCRIPTOR"; then +# if ! sed_in_place "$ANTORA_DESCRIPTOR" -E \ +# "s|^([[:space:]]*)current-full-version:.*|\\1current-full-version: '$RELEASED_VERSION'|"; then +# echo "❌ Got non-0 exit code from updating 'current-full-version', aborting." +# exit 1 +# fi +# echo "✅ '$ANTORA_DESCRIPTOR' now names $RELEASED_VERSION in its release runbooks." +# else +# echo "âš ī¸ '$ANTORA_DESCRIPTOR' has no 'current-full-version' attribute, skipping it." +# fi + +# # The copy of the download page on this branch is what ".../plc4x/latest/users/download.html" +# # will be served from, so it has to list the release too. +# update_download_page "$DIRECTORY/website/asciidoc/modules/users/pages/download.adoc" \ +# "$RELEASED_VERSION" "$DIRECTORY/RELEASE_NOTES" + +# if [[ $(git -C "$DIRECTORY" status --porcelain) ]]; then +# if ! git -C "$DIRECTORY" add "$ANTORA_DESCRIPTOR" \ +# "$DIRECTORY/website/asciidoc/modules/users/pages/download.adoc"; then +# echo "❌ Got non-0 exit code from adding the Antora descriptor, aborting." +# exit 1 +# fi +# if ! git -C "$DIRECTORY" commit -m "chore: published the documentation of $RELEASED_VERSION as the current release."; then +# echo "❌ Got non-0 exit code from committing the Antora descriptor, aborting." +# exit 1 +# files +# if ! git -C "$DIRECTORY" push; then +# echo "❌ Got non-0 exit code from pushing the Antora descriptor, aborting." +# exit 1 +# fi +# else +# echo "✅ The Antora descriptor was already up to date." +# fi + +# ######################################################################################################################## +# # 2. Update the website configuration on "develop" +# ######################################################################################################################## + +# # Two things have to happen on "develop" for this release to show up on the website: +# # +# # - the release branch has to be listed in the "content.sources" list of +# # website/antora-playbook.yml, because Antora only reads the branches listed there - without +# # it the descriptor changed above has no effect at all +# # - the released version has to be added to website/resources/plc4x-doap.rdf, which is what +# # Apache's tooling reads to keep track of the project's release activity +# # +# # Both files live on "develop", which is not the branch we are on, so the edits are made in a +# # throw-away worktree and pushed straight to "develop" - the release branch stays checked out in +# # the working copy the release manager is using. + +# if ! git -C "$DIRECTORY" fetch "$APACHE_REMOTE" develop; then +# echo "❌ Got non-0 exit code from fetching 'develop', aborting." +# exit 1 +# fi + +# DEVELOP_WORKTREE=$(mktemp -d) +# cleanup_develop_worktree() { +# git -C "$DIRECTORY" worktree remove --force "$DEVELOP_WORKTREE" >/dev/null 2>&1 +# rm -rf "$DEVELOP_WORKTREE" +# # If the remove failed the directory is gone but the .git/worktrees entry is not, so prune it. +# git -C "$DIRECTORY" worktree prune >/dev/null 2>&1 +# } +# trap cleanup_develop_worktree EXIT + +# # A detached worktree, so this works even if "develop" happens to be checked out somewhere else. +# rmdir "$DEVELOP_WORKTREE" +# if ! git -C "$DIRECTORY" worktree add --detach "$DEVELOP_WORKTREE" "$APACHE_REMOTE/develop"; then +# echo "❌ Got non-0 exit code from creating a worktree for 'develop', aborting." +# exit 1 +# fi + +# PLAYBOOK="$DEVELOP_WORKTREE/website/antora-playbook.yml" +# DOAP="$DEVELOP_WORKTREE/website/resources/plc4x-doap.rdf" +# for f in "$PLAYBOOK" "$DOAP"; do +# if [[ ! -f "$f" ]]; then +# echo "❌ '$f' not found, aborting." +# exit 1 +# fi +# done + +# CHANGES=() + +# ######################################################################################################################## +# # 2a. Add the release branch to the Antora playbook +# ######################################################################################################################## + +# if grep -q "'$RELEASE_BRANCH'" "$PLAYBOOK"; then +# echo "✅ '$RELEASE_BRANCH' is already listed in the Antora playbook." +# else +# # Insert directly after the first "start_path", which ends the "develop" source, so the +# # release branches stay listed newest first. +# if ! awk -v branch="$RELEASE_BRANCH" ' +# BEGIN { done = 0 } +# { print } +# !done && /^[[:space:]]*start_path: website\/asciidoc[[:space:]]*$/ { +# print " - url: https://github.com/apache/plc4x.git" +# print " branches: [\x27" branch "\x27]" +# print " start_path: website/asciidoc" +# done = 1 +# } +# ' "$PLAYBOOK" > "$PLAYBOOK.tmp"; then +# echo "❌ Got non-0 exit code from adding '$RELEASE_BRANCH' to the playbook, aborting." +# exit 1 +# fi +# if ! grep -q "'$RELEASE_BRANCH'" "$PLAYBOOK.tmp"; then +# echo "❌ Could not add '$RELEASE_BRANCH' to the Antora playbook, aborting." +# echo " The 'content.sources' list does not look the way this script expects." +# rm -f "$PLAYBOOK.tmp" +# exit 1 +# fi +# mv "$PLAYBOOK.tmp" "$PLAYBOOK" +# CHANGES+=("website/antora-playbook.yml") +# echo "✅ '$RELEASE_BRANCH' added to the Antora playbook." +# fi + +# ######################################################################################################################## +# # 2b. Add the released version to the DOAP file +# ######################################################################################################################## + +# if grep -q "$RELEASED_VERSION" "$DOAP"; then +# echo "✅ $RELEASED_VERSION is already listed in the DOAP file." +# else +# RELEASE_DATE=$(date +%Y-%m-%d) +# # The newest release is the one named "Latest", all the others are "Legacy", so the entry +# # that held the title up to now has to hand it over before the new one is inserted at the top. +# if ! sed_in_place "$DOAP" -E "s|Latest ([0-9][^<]*) release|Legacy \1 release|"; then +# echo "❌ Got non-0 exit code from demoting the previous release in the DOAP file, aborting." +# exit 1 +# fi +# if ! awk -v version="$RELEASED_VERSION" -v released="$RELEASE_DATE" ' +# BEGIN { done = 0 } +# !done && /^[[:space:]]*[[:space:]]*$/ { +# print " " +# print " " +# print " Latest " version " release" +# print " " released "" +# print " " version "" +# print " " +# print " " +# done = 1 +# } +# { print } +# ' "$DOAP" > "$DOAP.tmp"; then +# echo "❌ Got non-0 exit code from adding $RELEASED_VERSION to the DOAP file, aborting." +# exit 1 +# fi +# if ! grep -q "$RELEASED_VERSION" "$DOAP.tmp"; then +# echo "❌ Could not add $RELEASED_VERSION to the DOAP file, aborting." +# echo " The list of entries does not look the way this script expects." +# rm -f "$DOAP.tmp" +# exit 1 +# fi +# mv "$DOAP.tmp" "$DOAP" +# CHANGES+=("website/resources/plc4x-doap.rdf") +# echo "✅ $RELEASED_VERSION added to the DOAP file, released on $RELEASE_DATE." +# fi + +# ######################################################################################################################## +# # 2c. Add the release to the download page +# ######################################################################################################################## + +# # "develop" has its own copy of the download page, published as ".../plc4x/pre-release/...". The +# # notes are taken from the RELEASE_NOTES of the release branch, because the ones on "develop" have +# # already moved on to the next version. + +# DOWNLOAD_PAGE="$DEVELOP_WORKTREE/website/asciidoc/modules/users/pages/download.adoc" +# DOWNLOAD_PAGE_BEFORE=$(cksum < "$DOWNLOAD_PAGE") +# update_download_page "$DOWNLOAD_PAGE" "$RELEASED_VERSION" "$DIRECTORY/RELEASE_NOTES" +# if [[ "$(cksum < "$DOWNLOAD_PAGE")" != "$DOWNLOAD_PAGE_BEFORE" ]]; then +# CHANGES+=("website/asciidoc/modules/users/pages/download.adoc") +# fi + +# ######################################################################################################################## +# # 2d. Commit and push whatever changed +# ######################################################################################################################## + +# if [[ ${#CHANGES[@]} -eq 0 ]]; then +# echo "✅ The website configuration on 'develop' was already up to date." +# else +# if ! git -C "$DEVELOP_WORKTREE" add "${CHANGES[@]}"; then +# echo "❌ Got non-0 exit code from adding the changed files, aborting." +# exit 1 +# fi +# if ! git -C "$DEVELOP_WORKTREE" commit -m "chore: added $RELEASED_VERSION to the website configuration."; then +# echo "❌ Got non-0 exit code from committing the changed files, aborting." +# exit 1 +# files +# if ! git -C "$DEVELOP_WORKTREE" push "$APACHE_REMOTE" HEAD:develop; then +# echo "❌ Got non-0 exit code from pushing to 'develop', aborting." +# echo " Someone probably pushed to 'develop' in the meantime - re-run this script." +# exit 1 +# fi +# echo "✅ Pushed to 'develop': ${CHANGES[*]}" +# echo " Your local 'develop' is now behind by that commit - remember to pull it." +# fi + +# cleanup_develop_worktree +# trap - EXIT + +# NOTE: Old releases are not removed from the Antora playbook automatically - decide for yourself +# how many of them the website should keep building. + +######################################################################################################################## +# 3. Draft the [RESULT] and [ANNOUNCE] emails +######################################################################################################################## + +# Like the [VOTE] and [DISCUSS] emails drafted by 'release-2-prepare-release.sh', these are +# written to files for you to read and send yourself - nothing is sent from here. They are drafted +# before anything is published, so that a run that stops at the confirmation below still leaves you +# with the [RESULT] email needed to close the vote. + + +if ! command -v svn > /dev/null; then + echo "❌ 'svn' not found, but it is needed to publish the release artifacts, aborting." + exit 1 +fi + +# Everything below reads SVN to decide what to publish, so make sure we can actually see it. A +# listing that fails because of the network, a proxy or missing credentials looks exactly like +# "nothing is staged", and acting on that would mean publishing the wrong thing. +if ! svn ls "$DIST_DEV/" > /dev/null 2>&1; then + echo "❌ Could not list $DIST_DEV, aborting." + echo " Fix the connection (or your SVN credentials) and run this again." + exit 1 +fi +if ! svn ls "$DIST_RELEASE/" > /dev/null 2>&1; then + echo "❌ Could not list $DIST_RELEASE, aborting." + exit 1 +fi + +# Which release candidate won the vote: the highest numbered one that is still staged. Empty means +# there is genuinely nothing staged any more - the release was published and cleaned up already. +RELEASE_CANDIDATE=$(svn ls "$DIST_DEV/$RELEASED_VERSION/" 2>/dev/null \ + | grep -E '^rc[0-9]+/$' | sed 's|/$||' | sort -V | tail -1) +# Only ever used for the subject of the [RESULT] mail, never to decide what gets published. +RELEASE_CANDIDATE_LABEL=${RELEASE_CANDIDATE:-rc1} + +STAGE_DIR="$DIRECTORY/out/stage" +mkdir -p "$STAGE_DIR" + +echo +echo "The [RESULT] email needs the vote count." +read -r -p "Number of +1 votes by PMC members: " VOTES_PMC +read -r -p "Number of +1 votes by non-PMC members: " VOTES_NON_PMC +VOTES_PMC=${VOTES_PMC:-0} +VOTES_NON_PMC=${VOTES_NON_PMC:-0} + +cat > "$STAGE_DIR/result-email.eml" < "$STAGE_DIR/announce-email.eml" < /dev/null 2>&1; then + SVN_ALREADY_PUBLISHED=true + echo "✅ $RELEASED_VERSION is already published under $DIST_RELEASE." +else + if [[ -z "$RELEASE_CANDIDATE" ]]; then + echo "❌ Found no release candidate under $DIST_DEV/$RELEASED_VERSION/, aborting." + echo " Did 'release-2-prepare-release.sh' stage this release?" + exit 1 + fi + + echo + echo "About to publish Apache PLC4X Extras $RELEASED_VERSION. This cannot be undone:" + echo " - SVN: $DIST_DEV/$RELEASED_VERSION/$RELEASE_CANDIDATE" + echo " -> $DIST_RELEASE/$RELEASED_VERSION" + echo " - Nexus: releasing staging repository '$STAGING_REPO_ID' to the Apache release repo," + echo " from where it is synced to Maven Central." + read -r -p "Publish? (yes/no) " yn + case $yn in + yes ) echo "Publishing.";; + no ) echo "Nothing published. The documentation changes above are already done."; + exit 1;; + * ) echo "invalid response"; + exit 1;; + esac + + if ! svn move -m "Release Apache PLC4X Extras $RELEASED_VERSION" \ + "$DIST_DEV/$RELEASED_VERSION/$RELEASE_CANDIDATE" \ + "$DIST_RELEASE/$RELEASED_VERSION"; then + echo "❌ Got non-0 exit code from moving the release candidate in SVN, aborting." + exit 1 + fi + echo "✅ $RELEASE_CANDIDATE published as $DIST_RELEASE/$RELEASED_VERSION." + echo " The mirrors need up to 24 hours to pick this up - do not announce before that." +fi + +######################################################################################################################## +# 4c. Release the Nexus staging repository +######################################################################################################################## + +# Unlike the steps above there is no cheap way to ask Nexus whether this repository has already +# been released, so if SVN says the release is already out, ask rather than guess. Do NOT phrase +# that question as "it has probably been released already": the SVN move happens FIRST, so any run +# that died between the two halves leaves exactly this state with the Maven artifacts still +# unreleased - which is the whole reason this prompt exists. Guessing "already done" here is how +# 1.0.0 ended up published in SVN with its staging repository still CLOSED. +if [[ "$SVN_ALREADY_PUBLISHED" == "true" ]]; then + echo + echo "$RELEASED_VERSION is already published in SVN. That means one of two things:" + echo " - the release is fully done and you are simply re-running this script, or" + echo " - an earlier run moved the SVN part and then failed before releasing" + echo " '$STAGING_REPO_ID', so the Maven artifacts are still NOT released." + echo + echo "Check $NEXUS_URL under 'Staging Repositories'. If '$STAGING_REPO_ID' is" + echo "still listed there as CLOSED, it has not been released and the answer is 'yes'." + echo "Answering 'yes' for an already released repository publishes nothing twice - the" + echo "goal just fails and this script stops with an error you can ignore." + read -r -p "Release '$STAGING_REPO_ID' now? (yes/no) " yn + if [[ "$yn" != "yes" ]]; then + echo "✅ Leaving the Nexus staging repository alone." + STAGING_REPO_ID="" + fi +fi + +if [[ -n "$STAGING_REPO_ID" ]]; then +if ! MAVEN_OPTS="$NEXUS_MAVEN_OPTS" "$DIRECTORY/mvnw" -f "$DIRECTORY/tools/stage.pom" nexus-staging:rc-release \ + -DstagingRepositoryId="$STAGING_REPO_ID" -DstagingProfileId=$STAGING_PROFILE_ID; then + echo "❌ Got non-0 exit code from releasing the Nexus staging repository." + echo " If it was already released, this is expected and can be ignored - check" + echo " $NEXUS_URL under 'Staging Repositories'." + exit 1 +fi +echo "✅ Nexus staging repository '$STAGING_REPO_ID' released." +fi + +######################################################################################################################## +# 4d. Point the "release" branch at this release +######################################################################################################################## + +# The "release" branch carries the content of the newest release, as a merge of its tag, so that +# there is one branch to check out to get "what is released right now". It is maintained by hand +# up to and including 1.0.0, which is why it lagged a full release behind more than once. +# +# It must NOT be updated for every release. Once more than one branch is maintained at a time, a +# bugfix off an older line (0.13.2 while 1.0.0 is out) would otherwise pull the branch backwards. +# Same rule as "update_download_page" applies: only a version NEWER than what is already there. +# The check is a recommendation, not a veto - the question is always asked, because "which line is +# the current one" is a decision for the release manager, not for a "sort -V". + +if ! git -C "$DIRECTORY" fetch "$APACHE_REMOTE" release; then + echo "âš ī¸ Could not fetch the 'release' branch, skipping it." + echo " Merge '$RELEASE_TAG' into it by hand if it should carry $RELEASED_VERSION." +elif git -C "$DIRECTORY" merge-base --is-ancestor "$RELEASE_TAG" "$APACHE_REMOTE/release"; then + echo "✅ The 'release' branch already contains $RELEASE_TAG." +else + # What the branch carries now, so the prompt can say what would be replaced. + RELEASE_BRANCH_TAG=$(git -C "$DIRECTORY" describe --tags --abbrev=0 --match "v*" "$APACHE_REMOTE/release" 2>/dev/null) + RELEASE_BRANCH_VERSION=${RELEASE_BRANCH_TAG#v} + + echo + if [[ -z "$RELEASE_BRANCH_VERSION" ]]; then + echo "The 'release' branch carries no recognizable release tag." + RECOMMENDED="yes" + elif [[ "$(printf '%s\n%s\n' "$RELEASED_VERSION" "$RELEASE_BRANCH_VERSION" | sort -V | tail -1)" == "$RELEASED_VERSION" ]]; then + echo "The 'release' branch currently carries $RELEASE_BRANCH_VERSION, and $RELEASED_VERSION is newer." + RECOMMENDED="yes" + else + echo "The 'release' branch currently carries $RELEASE_BRANCH_VERSION, which is NEWER than" + echo "$RELEASED_VERSION. This looks like a bugfix release off an older maintained branch, and" + echo "'release' is supposed to carry the newest release - updating it would pull it backwards." + RECOMMENDED="no" + fi + echo "Recommended answer: $RECOMMENDED" + read -r -p "Merge $RELEASE_TAG into the 'release' branch? (yes/no) " yn + + if [[ "$yn" != "yes" ]]; then + echo "✅ Leaving the 'release' branch alone." + else + # Same throw-away worktree as the "develop" update above: the release branch stays checked + # out in the working copy the release manager is using. + RELEASE_WORKTREE=$(mktemp -d) + cleanup_release_worktree() { + git -C "$DIRECTORY" worktree remove --force "$RELEASE_WORKTREE" >/dev/null 2>&1 + rm -rf "$RELEASE_WORKTREE" + git -C "$DIRECTORY" worktree prune >/dev/null 2>&1 + } + trap cleanup_release_worktree EXIT + rmdir "$RELEASE_WORKTREE" + if ! git -C "$DIRECTORY" worktree add --detach "$RELEASE_WORKTREE" "$APACHE_REMOTE/release"; then + echo "❌ Got non-0 exit code from creating a worktree for 'release', aborting." + exit 1 + fi + + # The branch has commits of its own that are on no other branch ("chore: House-keeping + # after the release of ..."), so this can never fast-forward, and an ordinary merge would + # conflict its way through the whole tree. Take the two parents from a "-s ours" merge, + # which never conflicts, and then force the tree to be exactly the tag's - which is what + # the merges done by hand for the earlier releases ended up being. + if ! git -C "$RELEASE_WORKTREE" merge -s ours --no-commit "$RELEASE_TAG" \ + || ! git -C "$RELEASE_WORKTREE" read-tree -u --reset "$RELEASE_TAG" \ + || ! git -C "$RELEASE_WORKTREE" commit -m "Merge tag '$RELEASE_TAG' into release"; then + echo "❌ Got non-0 exit code from merging $RELEASE_TAG into 'release', aborting." + exit 1 + fi + + # "-s ours" keeps whatever the branch had if the read-tree above did not take, and that + # would publish the previous release under this one's name - so prove the tree matches. + if ! git -C "$RELEASE_WORKTREE" diff --quiet "$RELEASE_TAG" HEAD; then + echo "❌ The 'release' branch would not match $RELEASE_TAG, aborting without pushing." + exit 1 + fi + + # The merge has "release" as its first parent, so this fast-forwards - no force needed, + # which also means the ".asf.yaml" protection on the branch is never in the way. + if ! git -C "$RELEASE_WORKTREE" push "$APACHE_REMOTE" HEAD:release; then + echo "❌ Got non-0 exit code from pushing the 'release' branch, aborting." + exit 1 + fi + echo "✅ The 'release' branch now carries $RELEASED_VERSION." + cleanup_release_worktree + trap - EXIT + fi +fi + +######################################################################################################################## +# 5. Clean up what the release left behind +######################################################################################################################## + +######################################################################################################################## +# 5a. Remove the release candidates that did not make it from SVN +######################################################################################################################## + +# Only the winning release candidate was moved to the release part of SVN, so every earlier +# attempt is still sitting in the dev part, together with the now pointless version directory. +# None of it is needed once the release is out. + +if svn ls "$DIST_RELEASE/$RELEASED_VERSION/" > /dev/null 2>&1 \ + && svn ls "$DIST_DEV/$RELEASED_VERSION/" > /dev/null 2>&1; then + echo + echo "These leftovers of $RELEASED_VERSION are still staged under $DIST_DEV/$RELEASED_VERSION:" + svn ls -R "$DIST_DEV/$RELEASED_VERSION/" | sed 's|^| |' + read -r -p "Remove them? (yes/no) " yn + if [[ "$yn" == "yes" ]]; then + if ! svn rm -m "Removed the staged release candidates of PLC4X $RELEASED_VERSION" \ + "$DIST_DEV/$RELEASED_VERSION"; then + echo "❌ Got non-0 exit code from removing the staged release candidates, aborting." + exit 1 + fi + echo "✅ Removed $DIST_DEV/$RELEASED_VERSION." + else + echo "✅ Leaving the staged release candidates alone." + fi +else + echo "✅ Nothing of $RELEASED_VERSION left to clean up in SVN." +fi + +######################################################################################################################## +# 5b. Remove the previous release from the mirrors +######################################################################################################################## + +# A lot of mirrors serve our releases, so Apache policy is that only the current one stays in the +# release part of SVN. Older releases remain available from archive.apache.org, and +# downloads.apache.org redirects to it for anything it no longer carries, so the links on the +# download page keep working. +# +# Only directories that look like a version are considered - "KEYS", "build-tools" and +# "plc4x-extras" live in the same place and are none of our business here. + +# Only versions OLDER than the one just released. Excluding just "$RELEASED_VERSION" would, for a +# bugfix release off an old branch (0.13.2 while 1.0.0 is current), offer to delete 1.0.0 - the +# release people are actually downloading. +OLD_RELEASES=$(svn ls "$DIST_RELEASE/" 2>/dev/null \ + | grep -E '^[0-9]+\.[0-9]+\.[0-9]+/$' | sed 's|/$||' \ + | while read -r candidate; do + if [[ "$candidate" != "$RELEASED_VERSION" ]] \ + && [[ "$(printf '%s\n%s\n' "$candidate" "$RELEASED_VERSION" | sort -V | head -1)" == "$candidate" ]]; then + echo "$candidate" + fi + done || true) + +if [[ -z "$OLD_RELEASES" ]]; then + echo "✅ $DIST_RELEASE only carries $RELEASED_VERSION." +else + echo + echo "These older releases are still being served by the mirrors:" + echo "$OLD_RELEASES" | sed 's|^| |' + echo "They stay available from https://archive.apache.org/dist/plc4x/ after removal." + read -r -p "Remove them? (yes/no) " yn + if [[ "$yn" == "yes" ]]; then + # shellcheck disable=SC2046 + if ! svn rm -m "Removed the older releases of PLC4X now that $RELEASED_VERSION is out" \ + $(echo "$OLD_RELEASES" | sed "s|^|$DIST_RELEASE/|"); then + echo "❌ Got non-0 exit code from removing the older releases, aborting." + exit 1 + fi + echo "✅ Removed: $(echo "$OLD_RELEASES" | tr '\n' ' ')" + else + echo "✅ Leaving the older releases in place." + fi +fi + +######################################################################################################################## +# 5c. Drop the staging repositories that were never released +######################################################################################################################## + +# A vote that had to be repeated leaves a closed staging repository behind for every attempt. +# Nexus does not tell us which of them belong to which attempt in any way we could rely on, so +# rather than guessing this lists what is there and lets you pick. + +echo +echo "Staging repositories currently on $NEXUS_URL:" +# No "-q" here: the repository table is printed at INFO level, and quiet mode would leave the +# prompt below asking for ids from a listing that was never shown. +if ! MAVEN_OPTS="$NEXUS_MAVEN_OPTS" "$DIRECTORY/mvnw" -f "$DIRECTORY/tools/stage.pom" nexus-staging:rc-list; then + echo "âš ī¸ Could not list the staging repositories - check them by hand at" + echo " $NEXUS_URL under 'Staging Repositories'." +else + echo + echo "Enter the ids of the repositories that should be dropped, separated by ','." + echo "Leave empty to keep all of them. The one released above is already gone." + read -r -p "Drop: " DROP_REPOS + DROP_REPOS=${DROP_REPOS// /} + if [[ -z "$DROP_REPOS" ]]; then + echo "✅ Keeping all staging repositories." + else + read -r -p "Really drop '$DROP_REPOS'? (yes/no) " yn + if [[ "$yn" != "yes" ]]; then + echo "✅ Keeping all staging repositories." + elif ! MAVEN_OPTS="$NEXUS_MAVEN_OPTS" "$DIRECTORY/mvnw" -f "$DIRECTORY/tools/stage.pom" nexus-staging:rc-drop \ + -DstagingRepositoryId="$DROP_REPOS"; then + echo "❌ Got non-0 exit code from dropping the staging repositories, aborting." + exit 1 + else + echo "✅ Dropped: $DROP_REPOS" + fi + fi +fi diff --git a/tools/release-abort.sh b/tools/release-abort.sh new file mode 100755 index 000000000..7774506f0 --- /dev/null +++ b/tools/release-abort.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash + +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# ---------------------------------------------------------------------------- + +# Resolve the project directory from the location of this script, so that it does not matter which +# directory it is started from. This used to be "$(pwd)" with a "find .." further down, which +# deleted files from the PARENT of the checkout - and that is where the plc4x-build-tools and +# plc4x-extras checkouts usually live - whenever the script was not started from "tools". +DIRECTORY="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# Values shared with the other release scripts (Nexus staging profile, dist.apache.org URLs) and the +# ownership settings for the docker builds. +if [[ ! -f "$DIRECTORY/tools/release-common.sh" ]]; then + echo "❌ '$DIRECTORY/tools/release-common.sh' not found, aborting." + exit 1 +fi +# shellcheck source=release-common.sh +source "$DIRECTORY/tools/release-common.sh" + +# The release branch and the release tag belong in the Apache repository, which is not necessarily +# the remote called "origin" - see "resolve_apache_remote" in "release-common.sh". +require_apache_remote +echo "Apache remote: $APACHE_REMOTE ($APACHE_REMOTE_URL)" + +######################################################################################################################## +# 1. Work out what to roll the versions back to +######################################################################################################################## + +# Maven 4 prefixes even quiet output with "[INFO] [stdout] ", so take the last token of the +# last line rather than the whole output. +CURRENT_VERSION=$("$DIRECTORY/mvnw" -f "$DIRECTORY/pom.xml" -q --non-recursive -Dexpression=project.version -DforceStdout help:evaluate | tail -n 1 | awk '{print $NF}') + +# The version to go back to cannot be derived reliably: after "release:branch" the poms hold the +# next minor version, after "release:prepare" the next bugfix version, and in between the release +# version itself. So take it as an argument, or ask. +PRE_RELEASE_VERSION="$1" +if [[ -z "$PRE_RELEASE_VERSION" ]]; then + echo "The poms currently say: $CURRENT_VERSION" + read -r -p "Which version should they be set back to? " PRE_RELEASE_VERSION +fi +if [[ -z "$PRE_RELEASE_VERSION" ]]; then + echo "❌ No version given, aborting." + exit 1 +fi +if [[ ! "$PRE_RELEASE_VERSION" =~ -SNAPSHOT$ ]]; then + echo "❌ '$PRE_RELEASE_VERSION' is not a SNAPSHOT version, aborting." + echo " The development version is what this rolls back to, for example 1.0.0-SNAPSHOT." + exit 1 +fi + +RELEASE_VERSION=${PRE_RELEASE_VERSION%"-SNAPSHOT"} +IFS='.' read -ra VERSION_SEGMENTS <<< "$RELEASE_VERSION" +BRANCH_NAME="rel/${VERSION_SEGMENTS[0]}.${VERSION_SEGMENTS[1]}" +TAG_NAME="v$RELEASE_VERSION" + +echo "Rolling back to: $PRE_RELEASE_VERSION" +echo "Release branch: $BRANCH_NAME" +echo "Release tag: $TAG_NAME" + +######################################################################################################################## +# 2. Set the versions back (Docker container) +######################################################################################################################## + +if ! docker compose -f "$DIRECTORY/tools/docker-compose.yaml" run releaser \ + bash /ws/mvnw -e -P with-c,with-go,with-java, \ + -Dmaven.repo.local=/ws/out/.repository versions:set -DprocessAllModules=true -DnewVersion="$PRE_RELEASE_VERSION"; then + echo "❌ Got non-0 exit code from setting the versions back, aborting." + exit 1 +fi + +######################################################################################################################## +# 3. Delete the files the release plugin left behind (local) +######################################################################################################################## + +find "$DIRECTORY" -type f -name 'release.properties' -delete +find "$DIRECTORY" -type f -name 'pom.xml.versionsBackup' -delete +find "$DIRECTORY" -type f -name 'pom.xml.releaseBackup' -delete +echo "✅ Removed the left-over release.properties and pom backup files." + +######################################################################################################################## +# 4. Delete the release branch and the tag +######################################################################################################################## + +# These are separate questions because an attempt can fail before either of them was created, and +# because deleting them on the remote is not something to do by accident. +delete_ref() { + local kind="$1" name="$2" local_cmd="$3" remote_cmd="$4" + local exists_local=false exists_remote=false + if [[ "$kind" == "branch" ]]; then + git -C "$DIRECTORY" show-ref --verify --quiet "refs/heads/$name" && exists_local=true + else + git -C "$DIRECTORY" show-ref --verify --quiet "refs/tags/$name" && exists_local=true + fi + git -C "$DIRECTORY" ls-remote --exit-code "$APACHE_REMOTE" "$name" > /dev/null 2>&1 && exists_remote=true + + if [[ "$exists_local" == false && "$exists_remote" == false ]]; then + echo "✅ No $kind '$name' to delete." + return 0 + fi + echo + echo "$kind '$name' exists: local=$exists_local remote=$exists_remote" + read -r -p "Delete it? (yes/no) " yn + if [[ "$yn" != "yes" ]]; then + echo "✅ Keeping $kind '$name'." + return 0 + fi + if [[ "$exists_local" == true ]]; then + eval "$local_cmd" || echo "âš ī¸ Could not delete the local $kind '$name'." + fi + if [[ "$exists_remote" == true ]]; then + eval "$remote_cmd" || echo "âš ī¸ Could not delete the remote $kind '$name'." + fi + echo "✅ Deleted $kind '$name'." +} + +delete_ref tag "$TAG_NAME" \ + "git -C \"$DIRECTORY\" tag -d \"$TAG_NAME\"" \ + "git -C \"$DIRECTORY\" push $APACHE_REMOTE --delete \"$TAG_NAME\"" +delete_ref branch "$BRANCH_NAME" \ + "git -C \"$DIRECTORY\" branch -D \"$BRANCH_NAME\"" \ + "git -C \"$DIRECTORY\" push $APACHE_REMOTE --delete \"$BRANCH_NAME\"" + +######################################################################################################################## +# 5. What this does not undo +######################################################################################################################## + +cat </dev/null); then + APACHE_REMOTE="$PLC4X_REMOTE" + else + APACHE_REMOTE_URL="" + APACHE_REMOTE_PROBLEM="PLC4X_REMOTE is set to '$PLC4X_REMOTE', but this checkout has no remote of that name." + fi + return + fi + + # Anchored at the end so that, in the "plc4x" checkout, "apache/plc4x-extras" is not taken for + # "apache/plc4x". The optional ".git" and trailing slash cover the ways the URL gets written. + local pattern="(gitbox\.apache\.org[:/]repos/asf/${APACHE_REPO_NAME}|github\.com[:/]apache/${APACHE_REPO_NAME})(\.git)?/?$" + local matches=() + local name url + while read -r name url; do + [[ -z "$name" ]] && continue + if [[ "$url" =~ $pattern ]]; then + matches+=("$name") + fi + done < <(git -C "$DIRECTORY" remote -v 2>/dev/null | awk '$3 == "(fetch)" { print $1, $2 }') + + APACHE_REMOTE_MATCHES=${#matches[@]} + case ${#matches[@]} in + 0) + APACHE_REMOTE_PROBLEM="No remote of this checkout points at the Apache '$APACHE_REPO_NAME' repository." + ;; + 1) + APACHE_REMOTE="${matches[0]}" + APACHE_REMOTE_URL=$(git -C "$DIRECTORY" remote get-url "$APACHE_REMOTE" 2>/dev/null) + ;; + *) + # Deliberately not guessed at: which of them the release ends up in is not a decision + # a release script should make on its own. + APACHE_REMOTE_PROBLEM="Several remotes point at the Apache '$APACHE_REPO_NAME' repository: ${matches[*]}." + ;; + esac +} + +# Prints what the remotes of this checkout are and the two ways out of it. Only useful once +# "resolve_apache_remote" has failed. +apache_remote_help() { + local remotes + remotes=$(git -C "$DIRECTORY" remote -v 2>/dev/null | awk '$3 == "(fetch)" { printf " %-12s %s\n", $1, $2 }') + echo " Remotes of this checkout:" + if [[ -n "$remotes" ]]; then + echo "$remotes" + else + echo " (none)" + fi + if [[ "$APACHE_REMOTE_MATCHES" -eq 0 ]]; then + echo " Add the Apache repository as a remote:" + echo " git remote add apache https://github.com/apache/$APACHE_REPO_NAME.git" + echo " Or name the remote the release should use explicitly:" + else + echo " Name the remote the release should use explicitly:" + fi + echo " export PLC4X_REMOTE=" +} + +# For the scripts that push, fetch or query the remote: resolve it, or stop before anything is +# done in the wrong repository. +require_apache_remote() { + resolve_apache_remote + if [[ -z "$APACHE_REMOTE" ]]; then + echo "❌ $APACHE_REMOTE_PROBLEM" + apache_remote_help + exit 1 + fi +} + +# Resolved once here so that every script that sources this file has APACHE_REMOTE available. +# Scripts that actually talk to the remote call "require_apache_remote" to insist on it. +resolve_apache_remote diff --git a/tools/release-preflight.sh b/tools/release-preflight.sh new file mode 100755 index 000000000..87f887168 --- /dev/null +++ b/tools/release-preflight.sh @@ -0,0 +1,349 @@ +#!/usr/bin/env bash + +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# ---------------------------------------------------------------------------- + +# Checks everything a release needs before any of it is done for real. +# +# A release runs for the better part of an hour, and almost everything that has gone wrong with it +# so far was a precondition that could have been checked in seconds: a Nexus password that had +# expired, a signing key that is not in the KEYS file, a gpg agent that cannot sign inside the +# release container. This script gathers those checks in one place, so a release fails in a minute +# rather than after the third full build of the day. +# +# It only reads: nothing here creates a commit, a tag, a staging repository or an SVN directory. +# +# tools/release-preflight.sh run every check +# tools/release-preflight.sh --offline skip the checks that need the network +# +# It is meant to be run at the start of the release scripts, and can be run on its own at any time. + +DIRECTORY="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if [[ ! -f "$DIRECTORY/tools/release-common.sh" ]]; then + echo "❌ '$DIRECTORY/tools/release-common.sh' not found, aborting." + exit 1 +fi +# shellcheck source=release-common.sh +source "$DIRECTORY/tools/release-common.sh" + +OFFLINE=false +if [[ "$1" == "--offline" ]]; then + OFFLINE=true +fi + +# Every check adds to one of these, and the summary at the end decides the exit code. Checks do +# not abort the script on purpose: a release manager should see everything that needs fixing in +# one go, instead of running into the problems one release attempt at a time. +FAILURES=() +WARNINGS=() + +fail() { + echo "❌ $1" + FAILURES+=("$1") +} + +warn() { + echo "âš ī¸ $1" + WARNINGS+=("$1") +} + +ok() { + echo "✅ $1" +} + +######################################################################################################################## +# 1. The tools the release scripts call +######################################################################################################################## + +echo +echo "── Tools ─────────────────────────────────────────────────────────────────────" + +for tool in git docker gpg ssh svn curl java shasum; do + if command -v "$tool" > /dev/null 2>&1; then + ok "'$tool' is installed" + else + fail "'$tool' is not installed, the release scripts need it." + fi +done + +# The whole build runs in a container, so a docker binary without a running daemon gets us nowhere. +if command -v docker > /dev/null 2>&1; then + if docker info > /dev/null 2>&1; then + ok "The docker daemon is running" + else + fail "The docker daemon is not running, start Docker Desktop before releasing." + fi +fi + +######################################################################################################################## +# 2. Git +######################################################################################################################## + +echo +echo "── Git ───────────────────────────────────────────────────────────────────────" + +GIT_USER_NAME=$(git -C "$DIRECTORY" config user.name || git -C "$DIRECTORY" config --global user.name) +GIT_USER_EMAIL=$(git -C "$DIRECTORY" config user.email || git -C "$DIRECTORY" config --global user.email) +if [[ -n "$GIT_USER_NAME" && -n "$GIT_USER_EMAIL" ]]; then + ok "Git identity is set to '$GIT_USER_NAME <$GIT_USER_EMAIL>'" +else + fail "Git 'user.name' and/or 'user.email' are not configured." +fi + +if [[ $(git -C "$DIRECTORY" status --porcelain) ]]; then + warn "The working tree has uncommitted changes, the release scripts refuse to start like that." +else + ok "The working tree is clean" +fi + +# Nobody has to clone the Apache repository as "origin" - the contributor workflow documented on +# the website has you clone your own fork under that name and add the Apache one as a second +# remote. The release scripts therefore look the remote up by URL, and if that does not come down +# to exactly one remote, every push and fetch of the release would go to the wrong repository. +if [[ -n "$APACHE_REMOTE" ]]; then + ok "The Apache repository is the remote '$APACHE_REMOTE' ($APACHE_REMOTE_URL)" +else + fail "$APACHE_REMOTE_PROBLEM" + apache_remote_help +fi + +# Everything the release pushes - the release branch, the release commits, the release tag, the +# merges back into "develop" and "release" - goes out through these scripts, in the middle of a +# run that takes the better part of an hour. Over https every one of them needs a password or a +# credential helper; with an ssh key the agent already holds, none of them needs anything. The +# release is run over ssh for that reason, so an https remote is treated as a setup error here +# rather than as something to find out about between two builds. +if [[ -n "$APACHE_REMOTE" ]]; then + # "ssh://[user@]host[:port]/path" and the scp-like "[user@]host:path" are the two spellings + # git accepts for ssh. Anything else with a scheme - https, git, file - is not one of them. + SSH_TARGET="" + case "$APACHE_REMOTE_URL" in + ssh://*) + SSH_TARGET="${APACHE_REMOTE_URL#ssh://}" + SSH_TARGET="${SSH_TARGET%%/*}" + # A port would make this no longer a host name ssh can be handed on its own. + SSH_TARGET="${SSH_TARGET%:[0-9]*}" + ;; + *://*) + SSH_TARGET="" + ;; + *:*) + SSH_TARGET="${APACHE_REMOTE_URL%%:*}" + ;; + esac + # The part after "user@", for the messages - "github.com" reads better than "git@github.com". + SSH_HOST="${SSH_TARGET##*@}" + + if [[ -n "$SSH_TARGET" ]]; then + ok "The remote '$APACHE_REMOTE' uses ssh, so key based authentication applies" + else + fail "The remote '$APACHE_REMOTE' uses '$APACHE_REMOTE_URL', which needs a password or a credential helper the release cannot ask for." + echo " Switch it to ssh:" + echo " git remote set-url $APACHE_REMOTE git@github.com:apache/$APACHE_REPO_NAME.git" + fi + + if [[ "$OFFLINE" == false && -n "$SSH_TARGET" ]]; then + # "BatchMode" makes ssh fail rather than ask for a passphrase or a password, which is the + # situation the release is in. "accept-new" keeps a host this machine has never talked to + # from turning into a prompt. Neither GitHub nor gitbox give out a shell, so a successful + # login still exits non-zero - the greeting is what says it worked. + SSH_OUTPUT=$(ssh -T -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 "$SSH_TARGET" 2>&1) + SSH_STATUS=$? + # Both GitHub and gitbox greet with "Hi !", which is worth showing: it is the + # account the release will be pushed as, and not necessarily the one you expect. + SSH_USER=$(echo "$SSH_OUTPUT" | sed -n 's/^Hi \([^!]*\)!.*/\1/p' | head -n 1) + SSH_AS="" + if [[ -n "$SSH_USER" ]]; then + SSH_AS=" (as '$SSH_USER')" + fi + if [[ $SSH_STATUS -eq 0 ]] || echo "$SSH_OUTPUT" | grep -qiE "successfully authenticated|^Hi |welcome"; then + ok "ssh key based authentication to '$SSH_HOST' works$SSH_AS" + elif echo "$SSH_OUTPUT" | grep -qiE "permission denied|publickey|no such identity|host key verification failed|too many authentication failures"; then + fail "ssh key based authentication to '$SSH_HOST' does not work: $(echo "$SSH_OUTPUT" | tail -n 1)" + echo " The release pushes without a terminal, so the key has to work without a prompt:" + echo " ssh-add ~/.ssh/id_ed25519 # or whichever key is registered with $SSH_HOST" + echo " and the matching public key has to be registered with your $SSH_HOST account." + else + # A network that is down or a host that is unreachable says nothing about the key. + warn "Could not test ssh against '$SSH_HOST': $(echo "$SSH_OUTPUT" | tail -n 1)" + fi + fi +fi + +# Without a remote to push to there is nothing to authenticate against, and trying it against a +# fork would only prove access to the fork. +if [[ "$OFFLINE" == false && -n "$APACHE_REMOTE" ]]; then + # This authenticates against the remote without changing anything. It does not prove the push + # will be accepted: the ".asf.yaml" rulesets are evaluated on the real push, and a bypass only + # applies to the identity that ends up pushing. + PUSH_OUTPUT=$(git -C "$DIRECTORY" push --dry-run "$APACHE_REMOTE" HEAD 2>&1) + PUSH_STATUS=$? + if [[ $PUSH_STATUS -eq 0 ]]; then + ok "Can authenticate against '$APACHE_REMOTE' for pushing" + elif echo "$PUSH_OUTPUT" | grep -qiE "permission denied|authentication|403|could not read from remote|could not read username"; then + fail "Cannot push to '$APACHE_REMOTE' - check the remote, your ssh key and your access rights." + else + # A rejected push is a different matter than a rejected login: the branch may simply have + # moved on, which is not something this check has an opinion about. + warn "'git push --dry-run' did not succeed, but not for lack of access: $(echo "$PUSH_OUTPUT" | tail -n 1)" + fi +fi + +######################################################################################################################## +# 3. GPG +######################################################################################################################## + +echo +echo "── Signing ───────────────────────────────────────────────────────────────────" + +# The release is signed with whatever key git is configured to use, or the default secret key. +SIGNING_KEY=$(git -C "$DIRECTORY" config user.signingkey) +if [[ -z "$SIGNING_KEY" ]]; then + SIGNING_KEY=$(gpg --list-secret-keys --with-colons 2>/dev/null | awk -F: '/^sec:/ {print $5; exit}') +fi + +if [[ -z "$SIGNING_KEY" ]]; then + fail "No gpg secret key found, a release has to be signed." +else + ok "Signing with key '$SIGNING_KEY'" + + # An Apache release has to be signed with a key that carries the release manager's + # "{apache-id}@apache.org" address, so check that before the signing takes an hour to fail. + SIGNING_UIDS=$(gpg --with-colons --list-keys "$SIGNING_KEY" 2>/dev/null \ + | awk -F: '$1 == "uid" && $2 !~ /^[re]$/ {print $10}') + if echo "$SIGNING_UIDS" | grep -qiE '<[^>]+@apache\.org>'; then + ok "The signing key carries an apache.org address" + else + fail "The signing key '$SIGNING_KEY' carries no (valid) apache.org address." + fi + + # Signing hundreds of artifacts only to find out that the agent cannot sign is a long way to + # go for that news, so sign one throwaway file here. + SIGN_TEST_DIR=$(mktemp -d) + echo "plc4x release preflight" > "$SIGN_TEST_DIR/test.txt" + if gpg --batch --yes --local-user "$SIGNING_KEY" -ab "$SIGN_TEST_DIR/test.txt" > /dev/null 2>&1; then + ok "The signing key can sign without interaction" + else + fail "Cannot sign with '$SIGNING_KEY' - is the gpg agent running and the passphrase cached?" + fi + rm -rf "$SIGN_TEST_DIR" + + if [[ "$OFFLINE" == false ]]; then + # Being able to sign is not enough, the key also has to be published in the KEYS file, or + # nobody can verify the release. The release scripts check this after signing everything. + KEYS_DIR=$(mktemp -d) + if curl -fsSL "$KEYS_URL" -o "$KEYS_DIR/KEYS" && [[ -s "$KEYS_DIR/KEYS" ]]; then + SIGNING_FINGERPRINT=$(gpg --with-colons --fingerprint "$SIGNING_KEY" 2>/dev/null \ + | awk -F: '/^fpr:/ {print $10; exit}') + if gpg --no-default-keyring --keyring "$KEYS_DIR/pubring.kbx" --import "$KEYS_DIR/KEYS" > /dev/null 2>&1 \ + && gpg --no-default-keyring --keyring "$KEYS_DIR/pubring.kbx" --with-colons --fingerprint 2>/dev/null \ + | grep -q "$SIGNING_FINGERPRINT"; then + ok "The signing key is published in the KEYS file" + else + fail "The signing key is not in $KEYS_URL - add it before releasing." + fi + else + warn "Could not download the KEYS file from $KEYS_URL, skipping that check." + fi + rm -rf "$KEYS_DIR" + fi +fi + +######################################################################################################################## +# 4. Nexus +######################################################################################################################## + +if [[ "$OFFLINE" == false ]]; then + echo + echo "── Nexus ─────────────────────────────────────────────────────────────────────" + + # "rc-list" is the read-only counterpart of the staging deployment: same server id, same + # credentials, same "stage.pom", so it fails in exactly the same way a stale password would + # fail an hour into the release. Letting Maven do the call also means the credentials are + # read and decrypted the way Maven does it, rather than parsed out of settings.xml here. + NEXUS_OUTPUT=$(MAVEN_OPTS="$NEXUS_MAVEN_OPTS" "$DIRECTORY/mvnw" -q -f "$DIRECTORY/tools/stage.pom" \ + nexus-staging:rc-list -DstagingProfileId="$STAGING_PROFILE_ID" 2>&1) + NEXUS_STATUS=$? + if [[ $NEXUS_STATUS -eq 0 ]]; then + ok "Can authenticate against $NEXUS_URL with the 'apache.releases.https' credentials" + elif echo "$NEXUS_OUTPUT" | grep -q "401"; then + fail "$NEXUS_URL rejects the 'apache.releases.https' credentials from your settings.xml (401)." + else + fail "Could not talk to $NEXUS_URL, run 'MAVEN_OPTS=\"$NEXUS_MAVEN_OPTS\" mvnw -f tools/stage.pom nexus-staging:rc-list -DstagingProfileId=$STAGING_PROFILE_ID' to see why." + fi +fi + +######################################################################################################################## +# 5. SVN +######################################################################################################################## + +if [[ "$OFFLINE" == false ]]; then + echo + echo "── SVN ───────────────────────────────────────────────────────────────────────" + + # The release candidate is imported into "dist/dev", and the finished release is moved to + # "dist/release", so both have to be reachable. This only proves read access - a commit is the + # only thing that proves write access, and that is not something to do in a check. + if svn --non-interactive ls "$DIST_DEV" > /dev/null 2>&1; then + ok "'$DIST_DEV' is reachable" + else + fail "Cannot read '$DIST_DEV' - are your svn credentials cached?" + fi +fi + +######################################################################################################################## +# 6. Disk space +######################################################################################################################## + +echo +echo "── Resources ─────────────────────────────────────────────────────────────────" + +# A full release build with all languages, the local repository and the staged artifacts needs +# a good deal of room, and running out halfway through is a particularly annoying way to fail. +# "df -k" is the portable spelling, "df -g" only exists on macOS. +AVAILABLE_GB=$(df -k "$DIRECTORY" 2>/dev/null | awk 'NR==2 {print int($4 / 1024 / 1024)}') +if [[ -n "$AVAILABLE_GB" ]]; then + if [[ "$AVAILABLE_GB" -ge 20 ]]; then + ok "${AVAILABLE_GB}GB free on the volume holding the checkout" + else + warn "Only ${AVAILABLE_GB}GB free on the volume holding the checkout, a release needs about 20GB." + fi +fi + +######################################################################################################################## +# 7. Summary +######################################################################################################################## + +echo +echo "──────────────────────────────────────────────────────────────────────────────" +if [[ ${#WARNINGS[@]} -gt 0 ]]; then + echo "âš ī¸ ${#WARNINGS[@]} warning(s):" + for warning in "${WARNINGS[@]}"; do + echo " - $warning" + done +fi +if [[ ${#FAILURES[@]} -gt 0 ]]; then + echo "❌ ${#FAILURES[@]} problem(s) that will stop the release:" + for failure in "${FAILURES[@]}"; do + echo " - $failure" + done + exit 1 +fi +echo "✅ Everything the release needs is in place." diff --git a/tools/stage.pom b/tools/stage.pom new file mode 100644 index 000000000..8a6c2257b --- /dev/null +++ b/tools/stage.pom @@ -0,0 +1,47 @@ + + + + 4.1.0 + + org.apache.plc4x.extras + deploy-wrapper + 1.0.0 + pom + + + + + org.sonatype.plugins + nexus-staging-maven-plugin + 1.7.0 + true + + apache.releases.https + https://repository.apache.org/ + false + ../out/.local-artifacts-dir + + + + + \ No newline at end of file diff --git a/tools/validate-release.sh b/tools/validate-release.sh new file mode 100755 index 000000000..c3d983607 --- /dev/null +++ b/tools/validate-release.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash + +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# ---------------------------------------------------------------------------- + +# Resolve the project directory from the location of this script, so that it does not matter which +# directory it is started from. +DIRECTORY="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# Values shared with the other release scripts (Nexus staging profile, dist.apache.org URLs). +if [[ ! -f "$DIRECTORY/tools/release-common.sh" ]]; then + echo "❌ '$DIRECTORY/tools/release-common.sh' not found, aborting." + exit 1 +fi +# shellcheck source=release-common.sh +source "$DIRECTORY/tools/release-common.sh" + + +######################################################################################################################## +# 0. Check Docker Memory Availability +######################################################################################################################## + +# Minimum required memory in bytes (12 GB) +REQUIRED_MEM=$((12 * 1024 * 1024 * 1024)) + +# Extract total memory from `docker system info` +TOTAL_MEM=$(docker system info --format '{{.MemTotal}}') + +# Check if TOTAL_MEM was retrieved successfully +if [[ -z "$TOTAL_MEM" || "$TOTAL_MEM" -eq 0 ]]; then + echo "❌ Unable to determine total Docker memory. Is Docker running?" + exit 1 +fi + +# Compare and exit if not enough memory +if (( TOTAL_MEM < REQUIRED_MEM )); then + echo "❌ Docker runtime has insufficient memory: $(awk "BEGIN {printf \"%.2f\", $TOTAL_MEM/1024/1024/1024}") GB" + echo " At least 12 GB is required. Aborting." + exit 1 +fi + +######################################################################################################################## +# 1. Check that this is actually a release, and not a development checkout +######################################################################################################################## + +# "artifact:compare" compares what is built here against what is staged in Nexus. That only means +# anything if this really is the release: run against a "develop" checkout it would compare +# SNAPSHOT artifacts against a release repository and report differences that say nothing about +# the release candidate. + +# Maven 4 prefixes even quiet output with "[INFO] [stdout] ", so take the last token of the +# last line rather than the whole output. +PROJECT_VERSION=$("$DIRECTORY/mvnw" -f "$DIRECTORY/pom.xml" -q --non-recursive -Dexpression=project.version -DforceStdout help:evaluate | tail -n 1 | awk '{print $NF}') +if [[ -z "$PROJECT_VERSION" ]]; then + echo "❌ Could not determine the project version, aborting." + exit 1 +fi +if [[ "$PROJECT_VERSION" =~ -SNAPSHOT$ ]]; then + echo "❌ This is a SNAPSHOT checkout ($PROJECT_VERSION), aborting." + echo " Unpack the staged apache-plc4x--source-release.zip and run this in there," + echo " or check out the release tag." + exit 1 +fi +echo "✅ Validating Apache PLC4X Extras $PROJECT_VERSION" + +######################################################################################################################## +# 2. Do a simple release-perform command skip signing of artifacts and deploy to local directory +# (inside the Docker container) +######################################################################################################################## + +echo "Validate Release:" +if ! docker compose -f "$DIRECTORY/tools/docker-compose.yaml" build; then + echo "❌ Got non-0 exit code from building the release docker container, aborting." + exit 1 +fi + +# Only the Java artifacts are compared: the C, .Net and Python ones are either platform specific or +# not published to Maven at all, so there is nothing in the staging repository to compare them to. +if ! docker compose -f "$DIRECTORY/tools/docker-compose.yaml" run releaser \ + bash /ws/mvnw -e -P with-java -Dmaven.repo.local=/ws/out/.repository \ + -Dreference.repo="$NEXUS_URL/content/repositories/staging/" \ + -Dbuildinfo.reproducible verify artifact:compare; then + echo "❌ Got non-0 exit code from docker compose, aborting." + exit 1 +fi +echo "✅ The build of $PROJECT_VERSION matches the staged artifacts."