├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ ├── dependency-review.yml │ ├── go.yaml │ ├── release.yaml │ └── scorecards.yml ├── .gitignore ├── .goreleaser.yaml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── charts └── knx-exporter │ ├── .helmignore │ ├── Chart.yaml │ ├── templates │ ├── NOTES.txt │ ├── _helpers.tpl │ ├── configmap.yaml │ ├── deployment.yaml │ ├── service.yaml │ ├── serviceaccount.yaml │ └── validate.yaml │ └── values.yaml ├── cmd ├── completion.go ├── convertGa.go ├── convertGa_test.go ├── logging.go ├── root.go ├── run.go ├── run_test.go └── version.go ├── go.mod ├── go.sum ├── grafana-screenshot.png ├── main.go ├── pkg ├── .knx-exporter.yaml ├── knx │ ├── adapter.go │ ├── adapterMocks_test.go │ ├── config.go │ ├── config_test.go │ ├── converter.go │ ├── converter_test.go │ ├── export │ │ └── types.go │ ├── exporter.go │ ├── exporter_test.go │ ├── fake │ │ └── exporterMocks.go │ ├── fixtures │ │ ├── full-config.yaml │ │ ├── ga-config.yaml │ │ ├── ga-export.xml │ │ └── readConfig.yaml │ ├── group-address.go │ ├── group-address_test.go │ ├── listener.go │ ├── listener_test.go │ ├── physical-address.go │ ├── physical-address_test.go │ ├── poller.go │ ├── poller_test.go │ ├── snapshot.go │ ├── snapshotMocks_test.go │ └── snapshot_test.go ├── logging │ ├── logging.go │ └── logging_test.go ├── metrics │ ├── exporter.go │ └── fake │ │ └── exporterMocks.go └── utils │ └── file.go ├── scripts ├── defaultGaConfig.yaml ├── docker │ └── etc_passwd ├── postinstall.sh ├── preremove.sh └── systemd │ ├── knx-exporter.env │ └── knx-exporter.service ├── sonar-project.properties └── version └── version.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: chr-fritz 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | open-pull-requests-limit: 10 13 | - package-ecosystem: "github-actions" 14 | directory: "/" 15 | schedule: 16 | interval: "weekly" 17 | open-pull-requests-limit: 10 18 | 19 | - package-ecosystem: docker 20 | directory: / 21 | schedule: 22 | interval: daily 23 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | schedule: 21 | - cron: '40 0 * * 3' 22 | 23 | permissions: 24 | contents: read 25 | 26 | jobs: 27 | analyze: 28 | permissions: 29 | actions: read # for github/codeql-action/init to get workflow details 30 | contents: read # for actions/checkout to fetch code 31 | security-events: write # for github/codeql-action/analyze to upload SARIF results 32 | name: Analyze 33 | runs-on: ubuntu-latest 34 | 35 | strategy: 36 | fail-fast: false 37 | matrix: 38 | language: [ 'go' ] 39 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 40 | # Learn more: 41 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 42 | 43 | steps: 44 | - name: Checkout repository 45 | uses: actions/checkout@v4 46 | 47 | - name: Install Go 48 | uses: actions/setup-go@v5 49 | with: 50 | go-version-file: go.mod 51 | 52 | # Initializes the CodeQL tools for scanning. 53 | - name: Initialize CodeQL 54 | uses: github/codeql-action/init@v3 55 | with: 56 | languages: ${{ matrix.language }} 57 | # If you wish to specify custom queries, you can do so here or in a config file. 58 | # By default, queries listed here will override any specified in a config file. 59 | # Prefix the list here with "+" to use these queries and those in the config file. 60 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 61 | 62 | - name: Build Dependencies 63 | run: make buildDeps 64 | 65 | - name: Build 66 | run: make build 67 | 68 | - name: Perform CodeQL Analysis 69 | uses: github/codeql-action/analyze@v3 70 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | # Dependency Review Action 2 | # 3 | # This Action will scan dependency manifest files that change as part of a Pull Request, 4 | # surfacing known-vulnerable versions of the packages declared or updated in the PR. 5 | # Once installed, if the workflow run is marked as required, 6 | # PRs introducing known-vulnerable packages will be blocked from merging. 7 | # 8 | # Source repository: https://github.com/actions/dependency-review-action 9 | name: 'Dependency Review' 10 | on: [pull_request] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | dependency-review: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: 'Checkout Repository' 20 | uses: actions/checkout@v4 21 | - name: 'Dependency Review' 22 | uses: actions/dependency-review-action@v4 23 | -------------------------------------------------------------------------------- /.github/workflows/go.yaml: -------------------------------------------------------------------------------- 1 | name: Go build 2 | 3 | on: 4 | push: 5 | paths: 6 | - '.github/workflows/**' 7 | - 'cmd/**' 8 | - 'pkg/**' 9 | - 'version/**' 10 | - 'main.go' 11 | - 'go.mod' 12 | - 'go.sum' 13 | pull_request: 14 | paths: 15 | - '.github/workflows/**' 16 | - 'cmd/**' 17 | - 'pkg/**' 18 | - 'version/**' 19 | - 'main.go' 20 | - 'go.mod' 21 | - 'go.sum' 22 | workflow_dispatch: { } 23 | 24 | permissions: 25 | contents: read 26 | 27 | jobs: 28 | build: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v4 32 | with: 33 | fetch-depth: 0 34 | 35 | - name: Install Go 36 | uses: actions/setup-go@v5 37 | with: 38 | go-version-file: go.mod 39 | 40 | - name: Build Dependencies 41 | run: make buildDeps 42 | 43 | - name: Build 44 | run: make sonarcloud-version build 45 | 46 | - name: Test 47 | run: make ci-check 48 | 49 | - name: SonarCloud Scan 50 | uses: SonarSource/sonarcloud-github-action@master 51 | if: env.SONAR_TOKEN != '' 52 | env: 53 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any 54 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 55 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | permissions: { } 9 | 10 | jobs: 11 | release: 12 | permissions: 13 | contents: write 14 | packages: write 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | 22 | - name: Set up Go 23 | uses: actions/setup-go@v5 24 | with: 25 | go-version-file: go.mod 26 | 27 | - name: Login to Quay.io 28 | uses: docker/login-action@v3 29 | with: 30 | registry: quay.io 31 | username: ${{ secrets.QUAY_USER }} 32 | password: ${{ secrets.QUAY_ROBOT_TOKEN }} 33 | 34 | - name: Login to ghcr.io 35 | uses: docker/login-action@v3 36 | with: 37 | registry: ghcr.io 38 | username: ${{github.actor}} 39 | password: ${{secrets.GITHUB_TOKEN}} 40 | 41 | - name: Run GoReleaser 42 | uses: goreleaser/goreleaser-action@v6 43 | with: 44 | distribution: goreleaser 45 | version: latest 46 | args: release --clean 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GH_CHARTS_PRIVATE_TOKEN }} 49 | GOBIN: /usr/local/bin/ 50 | 51 | - name: Helm Installation 52 | uses: azure/setup-helm@v4 53 | with: 54 | version: "latest" # default is latest (stable) 55 | token: ${{ secrets.GITHUB_TOKEN }} 56 | 57 | - name: Helm Package 58 | run: helm package charts/${GITHUB_REPOSITORY#*/} --app-version "${GITHUB_REF_NAME}" --version "${GITHUB_REF_NAME#v}" 59 | 60 | - name: Helm Push 61 | run: | 62 | CHART_PACKAGE_NAME="${GITHUB_REPOSITORY#*/}-helm-${GITHUB_REF_NAME#v}.tgz" 63 | helm push "${CHART_PACKAGE_NAME}" oci://ghcr.io/$GITHUB_ACTOR 64 | -------------------------------------------------------------------------------- /.github/workflows/scorecards.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. They are provided 2 | # by a third-party and are governed by separate terms of service, privacy 3 | # policy, and support documentation. 4 | 5 | name: Scorecard supply-chain security 6 | on: 7 | # For Branch-Protection check. Only the default branch is supported. See 8 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection 9 | branch_protection_rule: 10 | # To guarantee Maintained check is occasionally updated. See 11 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained 12 | schedule: 13 | - cron: '20 7 * * 2' 14 | push: 15 | branches: ["main"] 16 | 17 | # Declare default permissions as read only. 18 | permissions: read-all 19 | 20 | jobs: 21 | analysis: 22 | name: Scorecard analysis 23 | runs-on: ubuntu-latest 24 | permissions: 25 | # Needed to upload the results to code-scanning dashboard. 26 | security-events: write 27 | # Needed to publish results and get a badge (see publish_results below). 28 | id-token: write 29 | contents: read 30 | actions: read 31 | 32 | steps: 33 | - name: "Checkout code" 34 | uses: actions/checkout@v4 35 | with: 36 | persist-credentials: false 37 | 38 | - name: "Run analysis" 39 | uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2 40 | with: 41 | results_file: results.sarif 42 | results_format: sarif 43 | # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: 44 | # - you want to enable the Branch-Protection check on a *public* repository, or 45 | # - you are installing Scorecards on a *private* repository 46 | # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat. 47 | # repo_token: ${{ secrets.SCORECARD_TOKEN }} 48 | 49 | # Public repositories: 50 | # - Publish results to OpenSSF REST API for easy access by consumers 51 | # - Allows the repository to include the Scorecard badge. 52 | # - See https://github.com/ossf/scorecard-action#publishing-results. 53 | # For private repositories: 54 | # - `publish_results` will always be set to `false`, regardless 55 | # of the value entered here. 56 | publish_results: true 57 | 58 | # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF 59 | # format to the repository Actions tab. 60 | - name: "Upload artifact" 61 | uses: actions/upload-artifact@v4 62 | with: 63 | name: SARIF file 64 | path: results.sarif 65 | retention-days: 5 66 | 67 | # Upload the results to GitHub's code scanning dashboard. 68 | - name: "Upload to code-scanning" 69 | uses: github/codeql-action/upload-sarif@v3 70 | with: 71 | sarif_file: results.sarif 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | .idea/ 3 | .iml 4 | completions/ 5 | bin/ 6 | reports/ 7 | .sonarcloud.properties 8 | test-values.yaml 9 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # Copyright © 2022-2024 Christian Fritz 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | version: 2 16 | before: 17 | hooks: 18 | - make buildDeps 19 | # You may remove this if you don't use go modules. 20 | - go mod download 21 | # you may remove this if you don't need go generate 22 | - make generate completions 23 | builds: 24 | - id: knx-exporter 25 | env: 26 | - CGO_ENABLED=0 27 | goos: 28 | - linux 29 | - darwin 30 | - windows 31 | goarch: 32 | - amd64 33 | - arm 34 | - arm64 35 | goarm: 36 | - "7" 37 | ldflags: 38 | - -X 'github.com/chr-fritz/knx-exporter/version.Version={{.Version}}' 39 | - -X 'github.com/chr-fritz/knx-exporter/version.Revision={{.ShortCommit}}' 40 | - -X 'github.com/chr-fritz/knx-exporter/version.Branch={{.Branch}}' 41 | - -X 'github.com/chr-fritz/knx-exporter/version.CommitDate={{.CommitTimestamp}}' 42 | - -s -w -extldflags '-static' 43 | ignore: 44 | - goos: windows 45 | goarch: arm 46 | dockers: 47 | - goos: linux 48 | goarch: amd64 49 | ids: 50 | - knx-exporter 51 | use: buildx 52 | image_templates: 53 | - "quay.io/chrfritz/knx-exporter:{{ .Tag }}-x64" 54 | - "ghcr.io/chr-fritz/knx-exporter:{{ .Tag }}-x64" 55 | skip_push: false 56 | dockerfile: Dockerfile 57 | build_flag_templates: 58 | - "--pull" 59 | - "--label=org.opencontainers.image.created={{.CommitDate}}" 60 | - "--label=org.opencontainers.image.title={{.ProjectName}}" 61 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 62 | - "--label=org.opencontainers.image.version={{.Version}}" 63 | - "--label=org.opencontainers.image.url=https://github.com/chr-fritz/knx-exporter" 64 | - "--label=org.opencontainers.image.licenses=Apache-2.0" 65 | - "--platform=linux/amd64" 66 | extra_files: 67 | - pkg/.knx-exporter.yaml 68 | - scripts/docker/etc_passwd 69 | - goos: linux 70 | goarch: arm64 71 | ids: 72 | - knx-exporter 73 | use: buildx 74 | image_templates: 75 | - "quay.io/chrfritz/knx-exporter:{{ .Tag }}-arm64" 76 | - "ghcr.io/chr-fritz/knx-exporter:{{ .Tag }}-arm64" 77 | skip_push: false 78 | dockerfile: Dockerfile 79 | build_flag_templates: 80 | - "--pull" 81 | - "--label=org.opencontainers.image.created={{.CommitDate}}" 82 | - "--label=org.opencontainers.image.title={{.ProjectName}}" 83 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 84 | - "--label=org.opencontainers.image.version={{.Version}}" 85 | - "--label=org.opencontainers.image.url=https://github.com/chr-fritz/knx-exporter" 86 | - "--label=org.opencontainers.image.licenses=Apache-2.0" 87 | - "--platform=linux/arm64/v8" 88 | extra_files: 89 | - pkg/.knx-exporter.yaml 90 | - scripts/docker/etc_passwd 91 | - goos: linux 92 | goarch: arm 93 | goarm: 7 94 | ids: 95 | - knx-exporter 96 | use: buildx 97 | image_templates: 98 | - "quay.io/chrfritz/knx-exporter:{{ .Tag }}-arm7" 99 | - "ghcr.io/chr-fritz/knx-exporter:{{ .Tag }}-arm7" 100 | skip_push: false 101 | dockerfile: Dockerfile 102 | build_flag_templates: 103 | - "--pull" 104 | - "--label=org.opencontainers.image.created={{.CommitDate}}" 105 | - "--label=org.opencontainers.image.title={{.ProjectName}}" 106 | - "--label=org.opencontainers.image.revision={{.FullCommit}}" 107 | - "--label=org.opencontainers.image.version={{.Version}}" 108 | - "--label=org.opencontainers.image.url=https://github.com/chr-fritz/knx-exporter" 109 | - "--label=org.opencontainers.image.licenses=Apache-2.0" 110 | - "--platform=linux/arm/v7" 111 | extra_files: 112 | - pkg/.knx-exporter.yaml 113 | - scripts/docker/etc_passwd 114 | docker_manifests: 115 | - name_template: "quay.io/chrfritz/knx-exporter:latest" 116 | skip_push: auto 117 | image_templates: 118 | - "quay.io/chrfritz/knx-exporter:{{ .Tag }}-x64" 119 | - "quay.io/chrfritz/knx-exporter:{{ .Tag }}-arm64" 120 | - "quay.io/chrfritz/knx-exporter:{{ .Tag }}-arm7" 121 | - name_template: "quay.io/chrfritz/knx-exporter:{{ .Tag }}" 122 | image_templates: 123 | - "quay.io/chrfritz/knx-exporter:{{ .Tag }}-x64" 124 | - "quay.io/chrfritz/knx-exporter:{{ .Tag }}-arm64" 125 | - "quay.io/chrfritz/knx-exporter:{{ .Tag }}-arm7" 126 | - name_template: "quay.io/chrfritz/knx-exporter:v{{ .Major }}.{{ .Minor }}" 127 | skip_push: auto 128 | image_templates: 129 | - "quay.io/chrfritz/knx-exporter:{{ .Tag }}-x64" 130 | - "quay.io/chrfritz/knx-exporter:{{ .Tag }}-arm64" 131 | - "quay.io/chrfritz/knx-exporter:{{ .Tag }}-arm7" 132 | - name_template: "quay.io/chrfritz/knx-exporter:v{{ .Major }}" 133 | skip_push: auto 134 | image_templates: 135 | - "quay.io/chrfritz/knx-exporter:{{ .Tag }}-x64" 136 | - "quay.io/chrfritz/knx-exporter:{{ .Tag }}-arm64" 137 | - "quay.io/chrfritz/knx-exporter:{{ .Tag }}-arm7" 138 | - name_template: "ghcr.io/chr-fritz/knx-exporter:latest" 139 | skip_push: auto 140 | image_templates: 141 | - "ghcr.io/chr-fritz/knx-exporter:{{ .Tag }}-x64" 142 | - "ghcr.io/chr-fritz/knx-exporter:{{ .Tag }}-arm64" 143 | - "ghcr.io/chr-fritz/knx-exporter:{{ .Tag }}-arm7" 144 | - name_template: "ghcr.io/chr-fritz/knx-exporter:{{ .Tag }}" 145 | image_templates: 146 | - "ghcr.io/chr-fritz/knx-exporter:{{ .Tag }}-x64" 147 | - "ghcr.io/chr-fritz/knx-exporter:{{ .Tag }}-arm64" 148 | - "ghcr.io/chr-fritz/knx-exporter:{{ .Tag }}-arm7" 149 | - name_template: "ghcr.io/chr-fritz/knx-exporter:v{{ .Major }}.{{ .Minor }}" 150 | skip_push: auto 151 | image_templates: 152 | - "ghcr.io/chr-fritz/knx-exporter:{{ .Tag }}-x64" 153 | - "ghcr.io/chr-fritz/knx-exporter:{{ .Tag }}-arm64" 154 | - "ghcr.io/chr-fritz/knx-exporter:{{ .Tag }}-arm7" 155 | - name_template: "ghcr.io/chr-fritz/knx-exporter:v{{ .Major }}" 156 | skip_push: auto 157 | image_templates: 158 | - "ghcr.io/chr-fritz/knx-exporter:{{ .Tag }}-x64" 159 | - "ghcr.io/chr-fritz/knx-exporter:{{ .Tag }}-arm64" 160 | - "ghcr.io/chr-fritz/knx-exporter:{{ .Tag }}-arm7" 161 | brews: 162 | - repository: 163 | owner: chr-fritz 164 | name: homebrew-tap 165 | commit_author: 166 | name: goreleaserbot 167 | email: goreleaser@chr-fritz.de 168 | directory: Formula 169 | homepage: "https://github.com/chr-fritz/knx-exporter" 170 | description: "The KNX Prometheus Exporter is a small bridge to export values measured by KNX sensors to Prometheus." 171 | license: "Apache-2.0" 172 | skip_upload: auto 173 | test: | 174 | system "#{bin}/knx-exporter --version" 175 | install: | 176 | bin.install "knx-exporter" 177 | bash_completion.install "completions/knx-exporter.bash" => "knx-exporter" 178 | zsh_completion.install "completions/knx-exporter.zsh" => "_knx-exporter" 179 | fish_completion.install "completions/knx-exporter.fish" 180 | nfpms: 181 | - package_name: knx-exporter 182 | file_name_template: "{{ .ConventionalFileName }}" 183 | builds: 184 | - knx-exporter 185 | vendor: chr-fritz 186 | homepage: https://github.com/chr-fritz/knx-exporter 187 | maintainer: chr-fritz 188 | license: Apache 2.0 189 | formats: 190 | - deb 191 | bindir: /usr/bin 192 | contents: 193 | # Config 194 | - src: scripts/defaultGaConfig.yaml 195 | dst: /etc/knx-exporter/ga-config.yaml 196 | type: "config|noreplace" 197 | # systemd 198 | - src: scripts/systemd/knx-exporter.service 199 | dst: /etc/systemd/system/knx-exporter.service 200 | - src: scripts/systemd/knx-exporter.env 201 | dst: /etc/default/knx-exporter 202 | type: "config|noreplace" 203 | # Completion 204 | - src: completions/knx-exporter.bash 205 | dst: /usr/share/bash-completion/completions/knx-exporter 206 | - src: completions/knx-exporter.fish 207 | dst: /usr/share/fish/vendor_completions.d/knx-exporter 208 | - src: completions/knx-exporter.zsh 209 | dst: /usr/share/zsh/vendor-completions/_knx-exporter 210 | scripts: 211 | postinstall: "scripts/postinstall.sh" 212 | preremove: "scripts/preremove.sh" 213 | archives: 214 | - files: 215 | - README.md 216 | - LICENSE 217 | - completions/* 218 | - docs/** 219 | checksum: 220 | name_template: 'checksums.txt' 221 | snapshot: 222 | name_template: "{{ .Tag }}-next" 223 | changelog: 224 | sort: asc 225 | filters: 226 | exclude: 227 | - '^docs:' 228 | - '^test:' 229 | release: 230 | prerelease: auto 231 | footer: | 232 | ## Docker Images 233 | 234 | [ghcr.io/chr-fritz/knx-exporter](https://ghcr.io/chr-fritz/knx-exporter) 235 | * `ghcr.io/chr-fritz/knx-exporter:latest` 236 | * `ghcr.io/chr-fritz/knx-exporter:{{ .Tag }}` 237 | * `ghcr.io/chr-fritz/knx-exporter:v{{ .Major }}.{{ .Minor }}` 238 | * `ghcr.io/chr-fritz/knx-exporter:v{{ .Major }}` 239 | 240 | [quay.io/chrfritz/knx-exporter](https://quay.io/chrfritz/knx-exporter) (deprecated) 241 | * `quay.io/chrfritz/knx-exporter:latest` 242 | * `quay.io/chrfritz/knx-exporter:{{ .Tag }}` 243 | * `quay.io/chrfritz/knx-exporter:v{{ .Major }}.{{ .Minor }}` 244 | * `quay.io/chrfritz/knx-exporter:v{{ .Major }}` 245 | 246 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Copyright © 2022-2024 Christian Fritz 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | FROM scratch 16 | LABEL org.opencontainers.image.description="The KNX Prometheus Exporter is a small bridge to export values measured by KNX sensors to Prometheus." 17 | COPY scripts/docker/etc_passwd /etc/passwd 18 | COPY knx-exporter / 19 | COPY pkg/.knx-exporter.yaml /etc/knx-exporter.yaml 20 | EXPOSE 8080/tcp 21 | EXPOSE 3671/udp 22 | VOLUME /etc/knx-exporter 23 | USER nonroot 24 | ENTRYPOINT ["/knx-exporter"] 25 | CMD ["run", "--config","/etc/knx-exporter.yaml"] 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2020-2022 Christian Fritz 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Copyright © 2022-2024 Christian Fritz 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | SHELL = /usr/bin/env bash -o pipefail -o errexit -o nounset 16 | NAME := knx-exporter 17 | ORG := chr-fritz 18 | ROOT_PACKAGE := github.com/chr-fritz/knx-exporter 19 | 20 | TAG_COMMIT := $(shell git rev-list --abbrev-commit --tags --max-count=1) 21 | TAG := $(shell git describe --abbrev=0 --tags ${TAG_COMMIT} 2>/dev/null || true) 22 | COMMIT := $(shell git rev-parse --short HEAD) 23 | VERSION := $(TAG:v%=%) 24 | ifneq ($(COMMIT), $(TAG_COMMIT)) 25 | VERSION := $(VERSION)-next 26 | endif 27 | 28 | 29 | REVISION := $(shell git rev-parse --short HEAD 2> /dev/null || echo 'unknown') 30 | BRANCH := $(shell git rev-parse --abbrev-ref HEAD 2> /dev/null || echo 'unknown') 31 | BUILD_DATE := $(shell git show -s --format=%ct) 32 | 33 | PACKAGE_DIRS := $(shell go list ./...) 34 | 35 | GOOS ?= $(shell go env GOOS) 36 | GOARCH ?= $(shell go env GOARCH) 37 | BUILD_DIR ?= ./bin 38 | REPORTS_DIR ?= ./reports 39 | 40 | BUILDFLAGS := -ldflags \ 41 | " -X '$(ROOT_PACKAGE)/version.Version=$(VERSION)'\ 42 | -X '$(ROOT_PACKAGE)/version.Revision=$(REVISION)'\ 43 | -X '$(ROOT_PACKAGE)/version.Branch=$(BRANCH)'\ 44 | -X '$(ROOT_PACKAGE)/version.CommitDate=$(BUILD_DATE)'\ 45 | -s -w -extldflags '-static'" 46 | 47 | .PHONY: all 48 | all: lint test $(GOOS)-build 49 | @echo "SUCCESS" 50 | 51 | .PHONY: ci 52 | ci: ci-check 53 | 54 | .PHONY: ci-check 55 | ci-check: lint tidy generate imports vet test 56 | 57 | .PHONY: build 58 | build: 59 | CGO_ENABLED=0 GOARCH=amd64 go build $(BUILDFLAGS) -o $(BUILD_DIR)/$(NAME) $(ROOT_PACKAGE) 60 | 61 | .PHONY: debug 62 | debug: 63 | CGO_ENABLED=0 GOARCH=amd64 go build -gcflags "all=-N -l" -o $(BUILD_DIR)/$(NAME)-debug $(ROOT_PACKAGE) 64 | dlv --listen=:2345 --headless=true --api-version=2 exec $(BUILD_DIR)/$(NAME)-debug run 65 | 66 | .PHONY: imports 67 | imports: 68 | find . -type f -name '*.go' ! -name '*_mocks.go' -print0 | xargs -0 goimports -w -l 69 | 70 | .PHONY: tidy 71 | tidy: 72 | go mod tidy 73 | 74 | .PHONY: darwin-build 75 | darwin-build: 76 | CGO_ENABLED=0 GOARCH=amd64 GOOS=darwin go build $(BUILDFLAGS) -o $(BUILD_DIR)/$(NAME)-darwin $(ROOT_PACKAGE) 77 | 78 | .PHONY: linux-build 79 | linux-build: 80 | CGO_ENABLED=0 GOARCH=amd64 GOOS=linux go build $(BUILDFLAGS) -o $(BUILD_DIR)/$(NAME)-linux $(ROOT_PACKAGE) 81 | 82 | .PHONY: windows-build 83 | windows-build: 84 | CGO_ENABLED=0 GOARCH=amd64 GOOS=windows go build $(BUILDFLAGS) -o $(BUILD_DIR)/$(NAME)-windows.exe $(ROOT_PACKAGE) 85 | 86 | .PHONY: test 87 | test: generate 88 | mkdir -p $(REPORTS_DIR) 89 | go test $(PACKAGE_DIRS) -coverprofile=$(REPORTS_DIR)/coverage.out -v $(PACKAGE_DIRS) | tee >(go tool test2json > $(REPORTS_DIR)/tests.json) 90 | 91 | .PHONY: test-race 92 | test-race: generate 93 | mkdir -p $(REPORTS_DIR) 94 | go test -race $(PACKAGE_DIRS) -coverprofile=$(REPORTS_DIR)/coverage.out -v $(PACKAGE_DIRS) | tee >(go tool test2json > $(REPORTS_DIR)/tests.json) 95 | 96 | .PHONY: cross 97 | cross: darwin-build linux-build windows-build 98 | 99 | .PHONY: vet 100 | vet: 101 | mkdir -p $(REPORTS_DIR) 102 | go vet -v $(PACKAGE_DIRS) 2> >(tee $(REPORTS_DIR)/vet.out) || true 103 | 104 | .PHONY: lint 105 | lint: 106 | mkdir -p $(REPORTS_DIR) 107 | # GOGC default is 100, but we need more aggressive GC to not consume too much memory 108 | # might not be necessary in future versions of golangci-lint 109 | # https://github.com/golangci/golangci-lint/issues/483 110 | GOGC=20 golangci-lint run --disable=typecheck --deadline=5m --out-format checkstyle > $(REPORTS_DIR)/lint.xml || true 111 | 112 | .PHONY: generate 113 | generate: 114 | go generate ./... 115 | 116 | .PHONY: clean 117 | clean: 118 | rm -rf $(BUILD_DIR) 119 | rm -rf release 120 | rm -rf $(REPORTS_DIR) 121 | 122 | .PHONY: buildDeps 123 | buildDeps: 124 | go mod download 125 | go install github.com/golang/mock/mockgen@latest 126 | go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest 127 | go install golang.org/x/tools/cmd/goimports@latest 128 | 129 | .PHONY: completions 130 | completions: 131 | rm -rf completions 132 | mkdir completions 133 | for sh in bash zsh fish ps1; do go run main.go completion "$$sh" >"completions/$(NAME).$$sh"; done 134 | 135 | .PHONY: sonarcloud-version 136 | sonarcloud-version: 137 | echo "sonar.projectVersion=$(VERSION)" >> sonar-project.properties 138 | -------------------------------------------------------------------------------- /charts/knx-exporter/.helmignore: -------------------------------------------------------------------------------- 1 | # Copyright © 2022-2024 Christian Fritz 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # Patterns to ignore when building packages. 16 | # This supports shell glob matching, relative path matching, and 17 | # negation (prefixed with !). Only one pattern per line. 18 | .DS_Store 19 | # Common VCS dirs 20 | .git/ 21 | .gitignore 22 | .bzr/ 23 | .bzrignore 24 | .hg/ 25 | .hgignore 26 | .svn/ 27 | # Common backup files 28 | *.swp 29 | *.bak 30 | *.tmp 31 | *.orig 32 | *~ 33 | # Various IDEs 34 | .project 35 | .idea/ 36 | *.tmproj 37 | .vscode/ 38 | test-values.yaml 39 | -------------------------------------------------------------------------------- /charts/knx-exporter/Chart.yaml: -------------------------------------------------------------------------------- 1 | # Copyright © 2022-2024 Christian Fritz 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | apiVersion: v2 16 | name: knx-exporter-helm 17 | description: The KNX Prometheus Exporter is a small bridge to export values measured by KNX sensors to Prometheus. 18 | 19 | # A chart can be either an 'application' or a 'library' chart. 20 | # 21 | # Application charts are a collection of templates that can be packaged into versioned archives 22 | # to be deployed. 23 | # 24 | # Library charts provide useful utilities or functions for the chart developer. They're included as 25 | # a dependency of application charts to inject those utilities and functions into the rendering 26 | # pipeline. Library charts do not define any templates and therefore cannot be deployed. 27 | type: application 28 | 29 | # This is the chart version. This version number should be incremented each time you make changes 30 | # to the chart and its templates, including the app version. 31 | # Versions are expected to follow Semantic Versioning (https://semver.org/) 32 | version: 0.1.0 33 | 34 | # This is the version number of the application being deployed. This version number should be 35 | # incremented each time you make changes to the application. Versions are not expected to 36 | # follow Semantic Versioning. They should reflect the version the application is using. 37 | # It is recommended to use it with quotes. 38 | appVersion: "1.16.0" 39 | -------------------------------------------------------------------------------- /charts/knx-exporter/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | 1. Get the application URL by running these commands: 2 | {{- if contains "NodePort" .Values.service.type }} 3 | export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "knx-exporter.fullname" . }}) 4 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") 5 | echo http://$NODE_IP:$NODE_PORT 6 | {{- else if contains "LoadBalancer" .Values.service.type }} 7 | NOTE: It may take a few minutes for the LoadBalancer IP to be available. 8 | You can watch its status by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "knx-exporter.fullname" . }}' 9 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "knx-exporter.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") 10 | echo http://$SERVICE_IP:{{ .Values.service.port }} 11 | {{- else if contains "ClusterIP" .Values.service.type }} 12 | export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "knx-exporter.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") 13 | export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") 14 | echo "Visit http://127.0.0.1:8080 to use your application" 15 | kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT 16 | {{- end }} 17 | -------------------------------------------------------------------------------- /charts/knx-exporter/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "knx-exporter.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "knx-exporter.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "knx-exporter.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "knx-exporter.labels" -}} 37 | helm.sh/chart: {{ include "knx-exporter.chart" . }} 38 | {{ include "knx-exporter.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "knx-exporter.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "knx-exporter.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{/* 54 | Create the name of the service account to use 55 | */}} 56 | {{- define "knx-exporter.serviceAccountName" -}} 57 | {{- if .Values.serviceAccount.create }} 58 | {{- default (include "knx-exporter.fullname" .) .Values.serviceAccount.name }} 59 | {{- else }} 60 | {{- default "default" .Values.serviceAccount.name }} 61 | {{- end }} 62 | {{- end }} 63 | -------------------------------------------------------------------------------- /charts/knx-exporter/templates/configmap.yaml: -------------------------------------------------------------------------------- 1 | # Copyright © 2022-2024 Christian Fritz 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | apiVersion: v1 15 | kind: ConfigMap 16 | metadata: 17 | name: {{ include "knx-exporter.fullname" . }} 18 | namespace: {{ .Release.Namespace }} 19 | labels: 20 | {{- include "knx-exporter.labels" . | nindent 4 }} 21 | data: 22 | knx-exporter.yaml: | 23 | connection: 24 | type: {{ .Values.connection.type | quote }} 25 | endpoint: {{ .Values.connection.endpoint | quote }} 26 | physicalAddress: {{ .Values.connection.physicalAddress | quote }} 27 | {{- with .Values.connection.tunnelConfig }} 28 | tunnelConfig: 29 | {{- toYaml . | nindent 8 }} 30 | {{- end }} 31 | {{- with .Values.connection.routerConfig }} 32 | routerConfig: 33 | {{- toYaml . | nindent 8 }} 34 | {{- end }} 35 | metricsPrefix: {{ .Values.metricsPrefix | quote }} 36 | addressConfigs: 37 | {{- toYaml .Values.addressConfigs | nindent 6 }} 38 | {{- with .Values.readStartupInterval }} 39 | readStartupInterval: {{ . }} 40 | {{- end }} 41 | -------------------------------------------------------------------------------- /charts/knx-exporter/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | # Copyright © 2022-2024 Christian Fritz 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | apiVersion: apps/v1 15 | kind: Deployment 16 | metadata: 17 | name: {{ include "knx-exporter.fullname" . }} 18 | namespace: {{ .Release.Namespace }} 19 | labels: 20 | {{- include "knx-exporter.labels" . | nindent 4 }} 21 | spec: 22 | replicas: 1 23 | selector: 24 | matchLabels: 25 | {{- include "knx-exporter.selectorLabels" . | nindent 6 }} 26 | template: 27 | metadata: 28 | {{- with .Values.podAnnotations }} 29 | annotations: 30 | {{- toYaml . | nindent 8 }} 31 | {{- end }} 32 | labels: 33 | {{- include "knx-exporter.labels" . | nindent 8 }} 34 | {{- with .Values.podLabels }} 35 | {{- toYaml . | nindent 8 }} 36 | {{- end }} 37 | spec: 38 | {{- with .Values.imagePullSecrets }} 39 | imagePullSecrets: 40 | {{- toYaml . | nindent 8 }} 41 | {{- end }} 42 | serviceAccountName: {{ include "knx-exporter.serviceAccountName" . }} 43 | securityContext: 44 | {{- toYaml .Values.podSecurityContext | nindent 8 }} 45 | containers: 46 | - name: {{ .Chart.Name }} 47 | args: 48 | - run 49 | - -f 50 | - /etc/knx-exporter/knx-exporter.yaml 51 | - -p 52 | - {{ .Values.service.port | quote }} 53 | - --withGoMetrics={{ .Values.exportGoMetrics }} 54 | - -r 55 | - health 56 | {{- with .Values.logging }} 57 | {{- with .format }} 58 | - --log_format={{ . }} 59 | {{- end }} 60 | {{- with .level }} 61 | - --log_level={{ . }} 62 | {{- end }} 63 | {{- end }} 64 | securityContext: 65 | {{- toYaml .Values.securityContext | nindent 12 }} 66 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" 67 | imagePullPolicy: {{ .Values.image.pullPolicy }} 68 | ports: 69 | - name: http 70 | containerPort: {{ .Values.service.port }} 71 | protocol: TCP 72 | livenessProbe: 73 | {{- toYaml .Values.livenessProbe | nindent 12 }} 74 | readinessProbe: 75 | {{- toYaml .Values.readinessProbe | nindent 12 }} 76 | resources: 77 | {{- toYaml .Values.resources | nindent 12 }} 78 | volumeMounts: 79 | - name: config 80 | mountPath: /etc/knx-exporter/ 81 | readOnly: true 82 | {{- with .Values.volumeMounts }} 83 | {{- toYaml . | nindent 12 }} 84 | {{- end }} 85 | volumes: 86 | - name: config 87 | configMap: 88 | name: {{ include "knx-exporter.fullname" . }} 89 | items: 90 | - key: knx-exporter.yaml 91 | path: knx-exporter.yaml 92 | {{- with .Values.volumes }} 93 | {{- toYaml . | nindent 8 }} 94 | {{- end }} 95 | {{- with .Values.nodeSelector }} 96 | nodeSelector: 97 | {{- toYaml . | nindent 8 }} 98 | {{- end }} 99 | {{- with .Values.affinity }} 100 | affinity: 101 | {{- toYaml . | nindent 8 }} 102 | {{- end }} 103 | {{- with .Values.tolerations }} 104 | tolerations: 105 | {{- toYaml . | nindent 8 }} 106 | {{- end }} 107 | -------------------------------------------------------------------------------- /charts/knx-exporter/templates/service.yaml: -------------------------------------------------------------------------------- 1 | # Copyright © 2022-2024 Christian Fritz 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | apiVersion: v1 15 | kind: Service 16 | metadata: 17 | name: {{ include "knx-exporter.fullname" . }} 18 | namespace: {{ .Release.Namespace }} 19 | labels: 20 | {{- include "knx-exporter.labels" . | nindent 4 }} 21 | spec: 22 | type: {{ .Values.service.type }} 23 | ports: 24 | - port: {{ .Values.service.port }} 25 | targetPort: http 26 | protocol: TCP 27 | name: http 28 | selector: 29 | {{- include "knx-exporter.selectorLabels" . | nindent 4 }} 30 | -------------------------------------------------------------------------------- /charts/knx-exporter/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | # Copyright © 2022-2024 Christian Fritz 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | {{- if .Values.serviceAccount.create -}} 15 | apiVersion: v1 16 | kind: ServiceAccount 17 | metadata: 18 | name: {{ include "knx-exporter.serviceAccountName" . }} 19 | namespace: {{ .Release.Namespace }} 20 | labels: 21 | {{- include "knx-exporter.labels" . | nindent 4 }} 22 | {{- with .Values.serviceAccount.annotations }} 23 | annotations: 24 | {{- toYaml . | nindent 4 }} 25 | {{- end }} 26 | automountServiceAccountToken: {{ .Values.serviceAccount.automount }} 27 | {{- end }} 28 | -------------------------------------------------------------------------------- /charts/knx-exporter/templates/validate.yaml: -------------------------------------------------------------------------------- 1 | {{- if not .Values.connection }} 2 | {{- fail "Missing connection configuration" }} 3 | {{- end }} 4 | 5 | {{- if not .Values.connection.endpoint }} 6 | {{- fail "Missing knx endpoint in `connection.endpoint`." }} 7 | {{- end }} 8 | 9 | {{- if not .Values.connection.physicalAddress }} 10 | {{- fail "Missing physical knx address in `connection.physicalAddress`." }} 11 | {{- end }} 12 | 13 | {{- if not .Values.connection.type }} 14 | {{- fail "Missing connection type in `connection.type`." }} 15 | {{- end }} 16 | 17 | {{- if not (or (eq .Values.connection.type "Tunnel") (eq .Values.connection.type "Router")) }} 18 | {{- fail "Connection type must be either `Tunnel` or `Router`" }} 19 | {{- end }} 20 | 21 | {{- if and (eq .Values.connection.type "Tunnel") (not .Values.connection.tunnelConfig) }} 22 | {{- fail "Connection type is Tunnel but `connection.tunnelConfig` is empty." }} 23 | {{- end }} 24 | 25 | {{- if and (eq .Values.connection.type "Router") (not .Values.connection.routerConfig) }} 26 | {{- fail "Connection type is Router but `connection.routerConfig` is empty." }} 27 | {{- end }} 28 | -------------------------------------------------------------------------------- /charts/knx-exporter/values.yaml: -------------------------------------------------------------------------------- 1 | # Copyright © 2022-2024 Christian Fritz 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # Default values for knx-exporter. 16 | # This is a YAML-formatted file. 17 | # Declare variables to be passed into your templates. 18 | 19 | image: 20 | repository: ghcr.io/chr-fritz/knx-exporter 21 | pullPolicy: IfNotPresent 22 | # Overrides the image tag whose default is the chart appVersion. 23 | tag: "" 24 | 25 | imagePullSecrets: [ ] 26 | nameOverride: "knx-exporter" 27 | fullnameOverride: "" 28 | 29 | serviceAccount: 30 | # Specifies whether a service account should be created 31 | create: true 32 | # Automatically mount a ServiceAccount's API credentials? 33 | automount: false 34 | # Annotations to add to the service account 35 | annotations: { } 36 | # The name of the service account to use. 37 | # If not set and create is true, a name is generated using the fullname template 38 | name: "" 39 | 40 | podAnnotations: { } 41 | podLabels: { } 42 | 43 | podSecurityContext: { } 44 | # fsGroup: 2000 45 | 46 | securityContext: { } 47 | # capabilities: 48 | # drop: 49 | # - ALL 50 | # readOnlyRootFilesystem: true 51 | # runAsNonRoot: true 52 | # runAsUser: 1000 53 | 54 | service: 55 | type: ClusterIP 56 | port: 8080 57 | 58 | resources: { } 59 | # We usually recommend not to specify default resources and to leave this as a conscious 60 | # choice for the user. This also increases chances charts run on environments with little 61 | # resources, such as Minikube. If you do want to specify resources, uncomment the following 62 | # lines, adjust them as necessary, and remove the curly braces after 'resources:'. 63 | # limits: 64 | # cpu: 100m 65 | # memory: 128Mi 66 | # requests: 67 | # cpu: 100m 68 | # memory: 128Mi 69 | 70 | livenessProbe: 71 | httpGet: 72 | path: /live 73 | port: http 74 | readinessProbe: 75 | httpGet: 76 | path: /ready 77 | port: http 78 | 79 | logging: 80 | # Defines the logging format. Possible values are "text" and "json" 81 | format: text 82 | # Defines the minimal log level that should be printed within the log on stdout. 83 | # Possible values are: 84 | # * panic 85 | # * fatal 86 | # * error 87 | # * warn 88 | # * warning 89 | # * info 90 | # * debug 91 | # * trace 92 | level: info 93 | # Export also go runtime metrics like cpu or memory usage 94 | exportGoMetrics: false 95 | # Defines the connection parameters. See https://github.com/chr-fritz/knx-exporter?tab=readme-ov-file#the-connection-section 96 | connection: 97 | type: "Tunnel" 98 | endpoint: "192.168.1.15:3671" 99 | physicalAddress: 2.0.1 100 | tunnelConfig: { } 101 | routerConfig: { } 102 | metricsPrefix: knx_ 103 | # Defines the exported group addresses. See https://github.com/chr-fritz/knx-exporter?tab=readme-ov-file#the-addressconfigs-section 104 | addressConfigs: { } 105 | # 0/0/1: 106 | # Name: dummy_metric 107 | # DPT: 1.* 108 | # Export: true 109 | # MetricType: "counter" 110 | # ReadActive: true 111 | # MaxAge: 10m 112 | # Comment: dummy comment 113 | # Labels: 114 | # room: office 115 | readStartupInterval: 500ms 116 | 117 | # Additional volumes on the output Deployment definition. 118 | volumes: [ ] 119 | # - name: foo 120 | # secret: 121 | # secretName: mysecret 122 | # optional: false 123 | 124 | # Additional volumeMounts on the output Deployment definition. 125 | volumeMounts: [ ] 126 | # - name: foo 127 | # mountPath: "/etc/foo" 128 | # readOnly: true 129 | 130 | nodeSelector: { } 131 | 132 | tolerations: [ ] 133 | 134 | affinity: { } 135 | -------------------------------------------------------------------------------- /cmd/completion.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "os" 19 | 20 | "github.com/spf13/cobra" 21 | ) 22 | 23 | // NewCompletionCmd creates the `completion` command 24 | func NewCompletionCmd() *cobra.Command { 25 | cmd := &cobra.Command{ 26 | Use: "completion [bash|zsh|fish|powershell]", 27 | Short: "Generate completion script", 28 | Long: `To load completions: 29 | 30 | Bash: 31 | 32 | $ source <(knx-exporter completion bash) 33 | 34 | # To load completions for each session, execute once: 35 | # Linux: 36 | $ knx-exporter completion bash > /etc/bash_completion.d/knx-exporter 37 | # macOS: 38 | $ knx-exporter completion bash > /usr/local/etc/bash_completion.d/knx-exporter 39 | 40 | Zsh: 41 | 42 | # If shell completion is not already enabled in your environment, 43 | # you will need to enable it. You can execute the following once: 44 | 45 | $ echo "autoload -U compinit; compinit" >> ~/.zshrc 46 | 47 | # To load completions for each session, execute once: 48 | $ knx-exporter completion zsh > "${fpath[1]}/_knx-exporter" 49 | 50 | # You will need to start a new shell for this setup to take effect. 51 | 52 | fish: 53 | 54 | $ knx-exporter completion fish | source 55 | 56 | # To load completions for each session, execute once: 57 | $ knx-exporter completion fish > ~/.config/fish/completions/knx-exporter.fish 58 | 59 | PowerShell: 60 | 61 | PS> knx-exporter completion powershell | Out-String | Invoke-Expression 62 | 63 | # To load completions for every new session, run: 64 | PS> knx-exporter completion powershell > knx-exporter.ps1 65 | # and source this file from your PowerShell profile. 66 | `, 67 | DisableFlagsInUseLine: true, 68 | ValidArgs: []string{"bash", "zsh", "fish", "powershell", "ps1"}, 69 | Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), 70 | Run: func(cmd *cobra.Command, args []string) { 71 | switch args[0] { 72 | case "bash": 73 | _ = cmd.Root().GenBashCompletion(os.Stdout) 74 | case "zsh": 75 | _ = cmd.Root().GenZshCompletion(os.Stdout) 76 | case "fish": 77 | _ = cmd.Root().GenFishCompletion(os.Stdout, true) 78 | case "powershell": 79 | fallthrough 80 | case "ps1": 81 | _ = cmd.Root().GenPowerShellCompletion(os.Stdout) 82 | } 83 | }, 84 | } 85 | 86 | return cmd 87 | } 88 | 89 | func init() { 90 | rootCmd.AddCommand(NewCompletionCmd()) 91 | } 92 | -------------------------------------------------------------------------------- /cmd/convertGa.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "github.com/spf13/cobra" 19 | 20 | "github.com/chr-fritz/knx-exporter/pkg/knx" 21 | ) 22 | 23 | type ConvertGaOptions struct{} 24 | 25 | func NewConvertGaOptions() *ConvertGaOptions { 26 | return &ConvertGaOptions{} 27 | } 28 | 29 | func NewConvertGaCommand() *cobra.Command { 30 | convertGaOptions := NewConvertGaOptions() 31 | 32 | return &cobra.Command{ 33 | Use: "convertGA [sourceFile] [targetFile]", 34 | Short: "Converts the ETS 5 XML group address export into the configuration format.", 35 | Long: `Converts the ETS 5 XML group address export into the configuration format. 36 | 37 | It takes the XML group address export from the ETS 5 tool and converts it into the yaml format 38 | used by the exporter.`, 39 | Args: cobra.ExactArgs(2), 40 | RunE: convertGaOptions.run, 41 | ValidArgsFunction: convertGaOptions.ValidArgs, 42 | } 43 | } 44 | 45 | // ValidArgs returns a list of possible arguments. 46 | func (i *ConvertGaOptions) ValidArgs(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { 47 | if len(args) == 0 { 48 | return []string{"xml"}, cobra.ShellCompDirectiveFilterFileExt 49 | } else { 50 | return nil, cobra.ShellCompDirectiveFilterDirs 51 | } 52 | } 53 | 54 | func (i *ConvertGaOptions) run(_ *cobra.Command, args []string) error { 55 | return knx.ConvertGroupAddresses(args[0], args[1]) 56 | } 57 | 58 | func init() { 59 | rootCmd.AddCommand(NewConvertGaCommand()) 60 | } 61 | -------------------------------------------------------------------------------- /cmd/convertGa_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "os" 19 | "testing" 20 | 21 | "github.com/stretchr/testify/assert" 22 | ) 23 | 24 | func TestRunConvertGaCommand(t *testing.T) { 25 | tests := []struct { 26 | name string 27 | src string 28 | wantErr bool 29 | }{ 30 | {"full", "../pkg/knx/fixtures/ga-export.xml", false}, 31 | {"source do not exists", "fixtures/invalid.xml", true}, 32 | } 33 | for _, tt := range tests { 34 | t.Run(tt.name, func(t *testing.T) { 35 | tmpFile, err := os.CreateTemp("", "") 36 | assert.NoError(t, err) 37 | defer func() { 38 | _ = os.Remove(tmpFile.Name()) 39 | }() 40 | cmd := NewConvertGaCommand() 41 | 42 | if err := cmd.RunE(nil, []string{tt.src, tmpFile.Name()}); (err != nil) != tt.wantErr { 43 | t.Errorf("ConvertGroupAddresses() error = %v, wantErr %v", err, tt.wantErr) 44 | return 45 | } 46 | if tt.wantErr { 47 | return 48 | } 49 | 50 | assert.FileExists(t, tmpFile.Name()) 51 | }) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /cmd/logging.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "github.com/chr-fritz/knx-exporter/pkg/logging" 19 | "github.com/spf13/cobra" 20 | ) 21 | 22 | func init() { 23 | loggerConfig := logging.InitFlags(rootCmd.PersistentFlags(), rootCmd) 24 | cobra.OnInitialize(loggerConfig.Initialize) 25 | } 26 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "fmt" 19 | "os" 20 | 21 | "github.com/mitchellh/go-homedir" 22 | "github.com/spf13/cobra" 23 | "github.com/spf13/viper" 24 | ) 25 | 26 | var rootCmd, rootCmdOptions = NewRootCommand() 27 | 28 | type RootOptions struct { 29 | configFile string 30 | } 31 | 32 | func NewRootOptions() *RootOptions { 33 | return &RootOptions{} 34 | } 35 | 36 | func NewRootCommand() (*cobra.Command, *RootOptions) { 37 | rootOptions := NewRootOptions() 38 | cmd := &cobra.Command{ 39 | Use: "knx-exporter", 40 | Short: "Exports KNX values to Prometheus", 41 | Long: `The KNX Prometheus Exporter is a small bridge to export values measured 42 | by KNX sensors to Prometheus. It takes the values either from cyclic 43 | sent "GroupValueWrite" telegrams and can request values itself using 44 | "GroupValueRead" telegrams.`, 45 | } 46 | 47 | cmd.PersistentFlags().StringVar(&rootOptions.configFile, "config", "", "config file (default is $HOME/.knx-exporter.yaml)") 48 | _ = cmd.RegisterFlagCompletionFunc("config", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { 49 | return []string{"yaml", "yml", "json"}, cobra.ShellCompDirectiveFilterFileExt 50 | }) 51 | return cmd, rootOptions 52 | } 53 | 54 | // initConfig reads in config file and ENV variables if set. 55 | func (o *RootOptions) initConfig() { 56 | if o.configFile != "" { 57 | // Use config file from the flag. 58 | viper.SetConfigFile(o.configFile) 59 | } else { 60 | // Find home directory. 61 | home, err := homedir.Dir() 62 | if err != nil { 63 | fmt.Println(err) 64 | os.Exit(1) 65 | } 66 | 67 | // Search config in home directory with name ".knx-exporter" (without extension). 68 | viper.AddConfigPath(home) 69 | viper.SetConfigName(".knx-exporter") 70 | } 71 | 72 | viper.AutomaticEnv() // read in environment variables that match 73 | 74 | // If a config file is found, read it in. 75 | if err := viper.ReadInConfig(); err != nil { 76 | fmt.Println(err) 77 | } 78 | } 79 | 80 | // Execute adds all child commands to the root command and sets flags appropriately. 81 | // This is called by main.main(). It only needs to happen once to the rootCmd. 82 | func Execute() { 83 | if err := rootCmd.Execute(); err != nil { 84 | fmt.Println(err) 85 | os.Exit(1) 86 | } 87 | } 88 | 89 | func init() { 90 | cobra.OnInitialize(rootCmdOptions.initConfig) 91 | } 92 | -------------------------------------------------------------------------------- /cmd/run.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "context" 19 | "log/slog" 20 | "os" 21 | "os/signal" 22 | "time" 23 | 24 | "github.com/coreos/go-systemd/v22/daemon" 25 | "github.com/heptiolabs/healthcheck" 26 | "github.com/spf13/cobra" 27 | "github.com/spf13/viper" 28 | 29 | "github.com/chr-fritz/knx-exporter/pkg/knx" 30 | "github.com/chr-fritz/knx-exporter/pkg/metrics" 31 | ) 32 | 33 | const RunPortParm = "exporter.port" 34 | const RunConfigFileParm = "exporter.configFile" 35 | const RunRestartParm = "exporter.restart" 36 | const WithGoMetricsParamName = "exporter.goMetrics" 37 | 38 | type RunOptions struct { 39 | aliveCheckInterval time.Duration 40 | } 41 | 42 | func NewRunOptions() *RunOptions { 43 | return &RunOptions{ 44 | aliveCheckInterval: 10 * time.Second, 45 | } 46 | } 47 | 48 | func NewRunCommand() *cobra.Command { 49 | runOptions := NewRunOptions() 50 | 51 | cmd := cobra.Command{ 52 | Use: "run", 53 | Short: "Run the exporter", 54 | Long: `Run the exporter which exports the received values from all configured Group Addresses to prometheus.`, 55 | Args: cobra.NoArgs, 56 | Run: runOptions.run, 57 | } 58 | 59 | cmd.Flags().Uint16P("port", "p", 8080, "The port where all metrics should be exported.") 60 | cmd.Flags().StringP("configFile", "f", "config.yaml", "The knx configuration file.") 61 | cmd.Flags().StringP("restart", "r", "health", "The restart behaviour. Can be health or exit") 62 | cmd.Flags().BoolP("withGoMetrics", "g", true, "Should the go metrics also be exported?") 63 | 64 | _ = viper.BindPFlag(RunPortParm, cmd.Flags().Lookup("port")) 65 | _ = viper.BindPFlag(RunConfigFileParm, cmd.Flags().Lookup("configFile")) 66 | _ = viper.BindPFlag(RunRestartParm, cmd.Flags().Lookup("restart")) 67 | _ = viper.BindPFlag(WithGoMetricsParamName, cmd.Flags().Lookup("withGoMetrics")) 68 | 69 | _ = cmd.RegisterFlagCompletionFunc("configFile", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { 70 | return []string{"yaml", "yml"}, cobra.ShellCompDirectiveFilterFileExt 71 | }) 72 | _ = cmd.RegisterFlagCompletionFunc("port", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { 73 | return nil, cobra.ShellCompDirectiveNoFileComp 74 | }) 75 | _ = cmd.RegisterFlagCompletionFunc("restart", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { 76 | return []string{"health", "exit"}, cobra.ShellCompDirectiveDefault 77 | }) 78 | return &cmd 79 | } 80 | 81 | func (i *RunOptions) run(_ *cobra.Command, _ []string) { 82 | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) 83 | defer stop() 84 | 85 | exporter := metrics.NewExporter(uint16(viper.GetUint(RunPortParm)), viper.GetBool(WithGoMetricsParamName)) 86 | 87 | exporter.AddLivenessCheck("goroutine-threshold", healthcheck.GoroutineCountCheck(100)) 88 | metricsExporter, err := i.initAndRunMetricsExporter(ctx, exporter) 89 | if err != nil { 90 | slog.Error("Unable to init metrics exporter: " + err.Error()) 91 | return 92 | } 93 | 94 | go i.aliveCheck(ctx, stop, metricsExporter) 95 | 96 | if err = exporter.Run(ctx); err != nil { 97 | slog.Error("Can not run metrics exporter: " + err.Error()) 98 | } 99 | } 100 | 101 | func (i *RunOptions) aliveCheck(ctx context.Context, cancelFunc context.CancelFunc, metricsExporter knx.MetricsExporter) { 102 | ticker := time.NewTicker(i.aliveCheckInterval) 103 | for { 104 | select { 105 | case <-ticker.C: 106 | aliveErr := metricsExporter.IsAlive() 107 | if aliveErr != nil { 108 | _, _ = daemon.SdNotify(false, "STATUS=Metrics Exporter is not alive anymore: "+aliveErr.Error()) 109 | _, _ = daemon.SdNotify(false, "ERROR=1") 110 | if viper.GetString(RunRestartParm) == "exit" { 111 | cancelFunc() 112 | } 113 | } 114 | case <-ctx.Done(): 115 | cancelFunc() 116 | } 117 | } 118 | } 119 | 120 | func (i *RunOptions) initAndRunMetricsExporter(ctx context.Context, exporter metrics.Exporter) (knx.MetricsExporter, error) { 121 | metricsExporter, err := knx.NewMetricsExporter(viper.GetString(RunConfigFileParm), exporter) 122 | if err != nil { 123 | return nil, err 124 | } 125 | 126 | exporter.AddLivenessCheck("knxConnection", metricsExporter.IsAlive) 127 | if e := metricsExporter.Run(ctx); e != nil { 128 | return nil, e 129 | } 130 | 131 | return metricsExporter, nil 132 | } 133 | 134 | func init() { 135 | rootCmd.AddCommand(NewRunCommand()) 136 | } 137 | -------------------------------------------------------------------------------- /cmd/run_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "context" 19 | "fmt" 20 | "testing" 21 | "time" 22 | 23 | "github.com/spf13/viper" 24 | 25 | knxFake "github.com/chr-fritz/knx-exporter/pkg/knx/fake" 26 | "github.com/golang/mock/gomock" 27 | ) 28 | 29 | func TestRunOptions_aliveCheck(t *testing.T) { 30 | ctrl := gomock.NewController(t) 31 | defer ctrl.Finish() 32 | ctx, cancelFunc := context.WithTimeout(context.Background(), 15*time.Millisecond) 33 | defer cancelFunc() 34 | 35 | viper.Set(RunRestartParm, "exit") 36 | i := &RunOptions{ 37 | aliveCheckInterval: 10 * time.Millisecond, 38 | } 39 | 40 | knxExporter := knxFake.NewMockMetricsExporter(ctrl) 41 | 42 | knxExporter.EXPECT(). 43 | IsAlive(). 44 | MinTimes(1). 45 | Return(nil). 46 | Return(fmt.Errorf("no connection")) 47 | 48 | go i.aliveCheck(ctx, cancelFunc, knxExporter) 49 | time.Sleep(20 * time.Millisecond) 50 | } 51 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package cmd 16 | 17 | import ( 18 | "fmt" 19 | "strconv" 20 | "time" 21 | 22 | "github.com/spf13/cobra" 23 | 24 | "github.com/chr-fritz/knx-exporter/version" 25 | ) 26 | 27 | type VersionOptions struct{} 28 | 29 | func NewVersionOptions() *VersionOptions { 30 | return &VersionOptions{} 31 | } 32 | 33 | func NewVersionCommand() *cobra.Command { 34 | versionOptions := NewVersionOptions() 35 | 36 | return &cobra.Command{ 37 | Use: "version", 38 | Short: "Show the version information", 39 | Args: cobra.NoArgs, 40 | Run: versionOptions.run, 41 | } 42 | } 43 | 44 | func (v *VersionOptions) run(_ *cobra.Command, _ []string) { 45 | parsedDate, _ := strconv.ParseInt(version.CommitDate, 10, 64) 46 | commitDate := time.Unix(parsedDate, 0).Format("2006-01-02 15:04:05 Z07:00") 47 | fmt.Printf(`KNX Prometheus Exporter 48 | Version: %s 49 | Commit: %s 50 | Commit Date: %s 51 | Branch: %s 52 | `, 53 | version.Version, 54 | version.Revision, 55 | commitDate, 56 | version.Branch, 57 | ) 58 | } 59 | 60 | func init() { 61 | rootCmd.AddCommand(NewVersionCommand()) 62 | } 63 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2023 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | module github.com/chr-fritz/knx-exporter 16 | 17 | go 1.23.0 18 | 19 | toolchain go1.24.1 20 | 21 | require ( 22 | github.com/coreos/go-systemd/v22 v22.5.0 23 | github.com/ghodss/yaml v1.0.0 24 | github.com/golang/mock v1.6.0 25 | github.com/heptiolabs/healthcheck v0.0.0-20211123025425-613501dd5deb 26 | github.com/mitchellh/go-homedir v1.1.0 27 | github.com/prometheus/client_golang v1.22.0 28 | github.com/spf13/cobra v1.9.1 29 | github.com/spf13/pflag v1.0.6 30 | github.com/spf13/viper v1.20.1 31 | github.com/stretchr/testify v1.10.0 32 | github.com/vapourismo/knx-go v0.0.0-20240915133544-a6ab43471c11 33 | ) 34 | 35 | require ( 36 | github.com/beorn7/perks v1.0.1 // indirect 37 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 38 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 39 | github.com/fsnotify/fsnotify v1.8.0 // indirect 40 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 41 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 42 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 43 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 44 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 45 | github.com/prometheus/client_model v0.6.1 // indirect 46 | github.com/prometheus/common v0.62.0 // indirect 47 | github.com/prometheus/procfs v0.15.1 // indirect 48 | github.com/sagikazarmark/locafero v0.7.0 // indirect 49 | github.com/sourcegraph/conc v0.3.0 // indirect 50 | github.com/spf13/afero v1.12.0 // indirect 51 | github.com/spf13/cast v1.7.1 // indirect 52 | github.com/subosito/gotenv v1.6.0 // indirect 53 | go.uber.org/multierr v1.11.0 // indirect 54 | golang.org/x/net v0.38.0 // indirect 55 | golang.org/x/sys v0.31.0 // indirect 56 | golang.org/x/text v0.23.0 // indirect 57 | google.golang.org/protobuf v1.36.5 // indirect 58 | gopkg.in/DATA-DOG/go-sqlmock.v1 v1.3.0 // indirect 59 | gopkg.in/yaml.v2 v2.4.0 // indirect 60 | gopkg.in/yaml.v3 v3.0.1 // indirect 61 | ) 62 | -------------------------------------------------------------------------------- /grafana-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chr-fritz/knx-exporter/ae23178949ae29efe3e95432f8e12b86038e942a/grafana-screenshot.png -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import "github.com/chr-fritz/knx-exporter/cmd" 18 | 19 | func main() { 20 | cmd.Execute() 21 | } 22 | -------------------------------------------------------------------------------- /pkg/.knx-exporter.yaml: -------------------------------------------------------------------------------- 1 | # Copyright © 2022-2024 Christian Fritz 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | exporter: 16 | port: 8080 17 | configFile: /etc/knx-exporter/ga-config.yaml 18 | -------------------------------------------------------------------------------- /pkg/knx/adapter.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package knx 16 | 17 | //go:generate mockgen -destination=adapterMocks_test.go -package=knx -source=adapter.go 18 | 19 | import ( 20 | "fmt" 21 | "github.com/vapourismo/knx-go/knx" 22 | "github.com/vapourismo/knx-go/knx/util" 23 | "log/slog" 24 | ) 25 | 26 | // GroupClient is a super interface for the knx.GroupClient interface to also export the Close() function. 27 | type GroupClient interface { 28 | Send(event knx.GroupEvent) error 29 | Inbound() <-chan knx.GroupEvent 30 | Close() 31 | } 32 | 33 | // DPT is wrapper interface for all types under github.com/vapourismo/knx-go/knx/dpt to simplifies working with them. 34 | type DPT interface { 35 | Pack() []byte 36 | Unpack(data []byte) error 37 | Unit() string 38 | String() string 39 | } 40 | 41 | func init() { 42 | util.Logger = knxLogger{} 43 | } 44 | 45 | type knxLogger struct{} 46 | 47 | func (l knxLogger) Printf(format string, args ...interface{}) { 48 | slog.Debug(fmt.Sprintf(format, args...)) 49 | } 50 | -------------------------------------------------------------------------------- /pkg/knx/adapterMocks_test.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: adapter.go 3 | 4 | // Package knx is a generated GoMock package. 5 | package knx 6 | 7 | import ( 8 | reflect "reflect" 9 | 10 | gomock "github.com/golang/mock/gomock" 11 | knx "github.com/vapourismo/knx-go/knx" 12 | ) 13 | 14 | // MockGroupClient is a mock of GroupClient interface. 15 | type MockGroupClient struct { 16 | ctrl *gomock.Controller 17 | recorder *MockGroupClientMockRecorder 18 | } 19 | 20 | // MockGroupClientMockRecorder is the mock recorder for MockGroupClient. 21 | type MockGroupClientMockRecorder struct { 22 | mock *MockGroupClient 23 | } 24 | 25 | // NewMockGroupClient creates a new mock instance. 26 | func NewMockGroupClient(ctrl *gomock.Controller) *MockGroupClient { 27 | mock := &MockGroupClient{ctrl: ctrl} 28 | mock.recorder = &MockGroupClientMockRecorder{mock} 29 | return mock 30 | } 31 | 32 | // EXPECT returns an object that allows the caller to indicate expected use. 33 | func (m *MockGroupClient) EXPECT() *MockGroupClientMockRecorder { 34 | return m.recorder 35 | } 36 | 37 | // Close mocks base method. 38 | func (m *MockGroupClient) Close() { 39 | m.ctrl.T.Helper() 40 | m.ctrl.Call(m, "Close") 41 | } 42 | 43 | // Close indicates an expected call of Close. 44 | func (mr *MockGroupClientMockRecorder) Close() *gomock.Call { 45 | mr.mock.ctrl.T.Helper() 46 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockGroupClient)(nil).Close)) 47 | } 48 | 49 | // Inbound mocks base method. 50 | func (m *MockGroupClient) Inbound() <-chan knx.GroupEvent { 51 | m.ctrl.T.Helper() 52 | ret := m.ctrl.Call(m, "Inbound") 53 | ret0, _ := ret[0].(<-chan knx.GroupEvent) 54 | return ret0 55 | } 56 | 57 | // Inbound indicates an expected call of Inbound. 58 | func (mr *MockGroupClientMockRecorder) Inbound() *gomock.Call { 59 | mr.mock.ctrl.T.Helper() 60 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Inbound", reflect.TypeOf((*MockGroupClient)(nil).Inbound)) 61 | } 62 | 63 | // Send mocks base method. 64 | func (m *MockGroupClient) Send(event knx.GroupEvent) error { 65 | m.ctrl.T.Helper() 66 | ret := m.ctrl.Call(m, "Send", event) 67 | ret0, _ := ret[0].(error) 68 | return ret0 69 | } 70 | 71 | // Send indicates an expected call of Send. 72 | func (mr *MockGroupClientMockRecorder) Send(event interface{}) *gomock.Call { 73 | mr.mock.ctrl.T.Helper() 74 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Send", reflect.TypeOf((*MockGroupClient)(nil).Send), event) 75 | } 76 | 77 | // MockDPT is a mock of DPT interface. 78 | type MockDPT struct { 79 | ctrl *gomock.Controller 80 | recorder *MockDPTMockRecorder 81 | } 82 | 83 | // MockDPTMockRecorder is the mock recorder for MockDPT. 84 | type MockDPTMockRecorder struct { 85 | mock *MockDPT 86 | } 87 | 88 | // NewMockDPT creates a new mock instance. 89 | func NewMockDPT(ctrl *gomock.Controller) *MockDPT { 90 | mock := &MockDPT{ctrl: ctrl} 91 | mock.recorder = &MockDPTMockRecorder{mock} 92 | return mock 93 | } 94 | 95 | // EXPECT returns an object that allows the caller to indicate expected use. 96 | func (m *MockDPT) EXPECT() *MockDPTMockRecorder { 97 | return m.recorder 98 | } 99 | 100 | // Pack mocks base method. 101 | func (m *MockDPT) Pack() []byte { 102 | m.ctrl.T.Helper() 103 | ret := m.ctrl.Call(m, "Pack") 104 | ret0, _ := ret[0].([]byte) 105 | return ret0 106 | } 107 | 108 | // Pack indicates an expected call of Pack. 109 | func (mr *MockDPTMockRecorder) Pack() *gomock.Call { 110 | mr.mock.ctrl.T.Helper() 111 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Pack", reflect.TypeOf((*MockDPT)(nil).Pack)) 112 | } 113 | 114 | // String mocks base method. 115 | func (m *MockDPT) String() string { 116 | m.ctrl.T.Helper() 117 | ret := m.ctrl.Call(m, "String") 118 | ret0, _ := ret[0].(string) 119 | return ret0 120 | } 121 | 122 | // String indicates an expected call of String. 123 | func (mr *MockDPTMockRecorder) String() *gomock.Call { 124 | mr.mock.ctrl.T.Helper() 125 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "String", reflect.TypeOf((*MockDPT)(nil).String)) 126 | } 127 | 128 | // Unit mocks base method. 129 | func (m *MockDPT) Unit() string { 130 | m.ctrl.T.Helper() 131 | ret := m.ctrl.Call(m, "Unit") 132 | ret0, _ := ret[0].(string) 133 | return ret0 134 | } 135 | 136 | // Unit indicates an expected call of Unit. 137 | func (mr *MockDPTMockRecorder) Unit() *gomock.Call { 138 | mr.mock.ctrl.T.Helper() 139 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Unit", reflect.TypeOf((*MockDPT)(nil).Unit)) 140 | } 141 | 142 | // Unpack mocks base method. 143 | func (m *MockDPT) Unpack(data []byte) error { 144 | m.ctrl.T.Helper() 145 | ret := m.ctrl.Call(m, "Unpack", data) 146 | ret0, _ := ret[0].(error) 147 | return ret0 148 | } 149 | 150 | // Unpack indicates an expected call of Unpack. 151 | func (mr *MockDPTMockRecorder) Unpack(data interface{}) *gomock.Call { 152 | mr.mock.ctrl.T.Helper() 153 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Unpack", reflect.TypeOf((*MockDPT)(nil).Unpack), data) 154 | } 155 | -------------------------------------------------------------------------------- /pkg/knx/config.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package knx 16 | 17 | import ( 18 | "encoding/json" 19 | "fmt" 20 | "net" 21 | "os" 22 | "strings" 23 | "time" 24 | 25 | "github.com/ghodss/yaml" 26 | "github.com/vapourismo/knx-go/knx" 27 | ) 28 | 29 | // Config defines the structure of the configuration file which defines which 30 | // KNX Group Addresses were mapped into prometheus metrics. 31 | type Config struct { 32 | Connection Connection `json:",omitempty"` 33 | // MetricsPrefix is a short prefix which will be added in front of the actual metric name. 34 | MetricsPrefix string 35 | AddressConfigs GroupAddressConfigSet 36 | // ReadStartupInterval is the intervall to wait between read of group addresses after startup. 37 | ReadStartupInterval Duration `json:",omitempty"` 38 | } 39 | 40 | // ReadConfig reads the given configuration file and returns the parsed Config object. 41 | func ReadConfig(configFile string) (*Config, error) { 42 | content, err := os.ReadFile(configFile) 43 | if err != nil { 44 | return nil, fmt.Errorf("can not read group address configuration: %s", err) 45 | } 46 | config := Config{ 47 | Connection: Connection{ 48 | RouterConfig: RouterConfig{ 49 | RetainCount: 32, 50 | MulticastLoopbackEnabled: false, 51 | PostSendPauseDuration: 20 * time.Millisecond, 52 | }, 53 | TunnelConfig: TunnelConfig{ 54 | ResendInterval: 500 * time.Millisecond, 55 | HeartbeatInterval: 10 * time.Second, 56 | ResponseTimeout: 10 * time.Second, 57 | SendLocalAddress: false, 58 | UseTCP: false, 59 | }, 60 | }, 61 | } 62 | err = yaml.Unmarshal(content, &config) 63 | if err != nil { 64 | return nil, fmt.Errorf("can not read config file %s: %s", configFile, err) 65 | } 66 | return &config, nil 67 | } 68 | 69 | // NameForGa returns the full metric name for the given GroupAddress. 70 | func (c *Config) NameForGa(address GroupAddress) string { 71 | gaConfig, ok := c.AddressConfigs[address] 72 | if !ok { 73 | return "" 74 | } 75 | return c.NameFor(gaConfig) 76 | } 77 | 78 | // NameFor return s the full metric name for the given GroupAddressConfig. 79 | func (c *Config) NameFor(gaConfig *GroupAddressConfig) string { 80 | return c.MetricsPrefix + gaConfig.Name 81 | } 82 | 83 | // Connection contains the information about how to connect to the KNX system and how to identify itself. 84 | type Connection struct { 85 | // Type of the actual connection. Can be either Tunnel or Router 86 | Type ConnectionType 87 | // Endpoint defines the IP address or hostname and port to where it should connect. 88 | Endpoint string 89 | // PhysicalAddress defines how the knx-exporter should identify itself within the KNX system. 90 | PhysicalAddress PhysicalAddress 91 | // RouterConfig contains some the specific configurations if connection Type is Router 92 | RouterConfig RouterConfig 93 | // TunnelConfig contains some the specific configurations if connection Type is Tunnel 94 | TunnelConfig TunnelConfig 95 | } 96 | 97 | type RouterConfig struct { 98 | // RetainCount specifies how many sent messages to retain. This is important for when a router indicates that it has 99 | // lost some messages. If you do not expect to saturate the router, keep this low. 100 | RetainCount uint 101 | // Interface specifies the network interface used to send and receive KNXnet/IP packets. If the interface is nil, the 102 | // system-assigned multicast interface is used. 103 | Interface string 104 | // MulticastLoopbackEnabled specifies if Multicast Loopback should be enabled. 105 | MulticastLoopbackEnabled bool 106 | // PostSendPauseDuration specifies the pause duration after sending. 0 means disabled. According to the specification, 107 | // we may choose to always pause for 20 ms after transmitting, but we should always pause for at least 5 ms on a 108 | // multicast address. 109 | PostSendPauseDuration time.Duration 110 | } 111 | 112 | func (rc RouterConfig) toKnxRouterConfig() (knx.RouterConfig, error) { 113 | var iface *net.Interface = nil 114 | if strings.Trim(rc.Interface, "\n\r\t ") != "" { 115 | var err error 116 | iface, err = net.InterfaceByName(rc.Interface) 117 | if err != nil { 118 | return knx.RouterConfig{}, err 119 | } 120 | } 121 | return knx.RouterConfig{ 122 | RetainCount: rc.RetainCount, 123 | Interface: iface, 124 | MulticastLoopbackEnabled: rc.MulticastLoopbackEnabled, 125 | PostSendPauseDuration: rc.PostSendPauseDuration, 126 | }, nil 127 | } 128 | 129 | type TunnelConfig struct { 130 | // ResendInterval is the interval with which requests will be resent if no response is received. 131 | ResendInterval time.Duration 132 | 133 | // HeartbeatInterval specifies the time interval which triggers a heartbeat check. 134 | HeartbeatInterval time.Duration 135 | 136 | // ResponseTimeout specifies how long to wait for a response. 137 | ResponseTimeout time.Duration 138 | 139 | // SendLocalAddress specifies if local address should be sent on connection request. 140 | SendLocalAddress bool 141 | 142 | // UseTCP configures whether to connect to the gateway using TCP. 143 | UseTCP bool 144 | } 145 | 146 | func (tc TunnelConfig) toKnxTunnelConfig() knx.TunnelConfig { 147 | return knx.TunnelConfig{ 148 | ResendInterval: tc.ResendInterval, 149 | HeartbeatInterval: tc.HeartbeatInterval, 150 | ResponseTimeout: tc.ResponseTimeout, 151 | SendLocalAddress: tc.SendLocalAddress, 152 | UseTCP: tc.UseTCP, 153 | } 154 | } 155 | 156 | type ConnectionType string 157 | 158 | const Tunnel = ConnectionType("Tunnel") 159 | const Router = ConnectionType("Router") 160 | 161 | func (t ConnectionType) MarshalJSON() ([]byte, error) { 162 | return json.Marshal(string(t)) 163 | } 164 | 165 | func (t *ConnectionType) UnmarshalJSON(data []byte) error { 166 | var str string 167 | if err := json.Unmarshal(data, &str); err != nil { 168 | return err 169 | } 170 | switch strings.ToLower(str) { 171 | case "tunnel": 172 | *t = Tunnel 173 | case "router": 174 | *t = Router 175 | default: 176 | return fmt.Errorf("invalid connection type given: \"%s\"", str) 177 | } 178 | return nil 179 | } 180 | 181 | type Duration time.Duration 182 | 183 | func (d Duration) MarshalJSON() ([]byte, error) { 184 | return json.Marshal(time.Duration(d).String()) 185 | } 186 | 187 | func (d *Duration) UnmarshalJSON(data []byte) error { 188 | var str string 189 | if err := json.Unmarshal(data, &str); err != nil { 190 | return err 191 | } 192 | duration, err := time.ParseDuration(str) 193 | if err != nil { 194 | return err 195 | } 196 | *d = Duration(duration) 197 | return nil 198 | } 199 | 200 | type ReadType string 201 | 202 | const GroupRead = ReadType("GroupRead") 203 | const WriteOther = ReadType("WriteOther") 204 | 205 | func (t ReadType) MarshalJSON() ([]byte, error) { 206 | return json.Marshal(string(t)) 207 | } 208 | 209 | func (t *ReadType) UnmarshalJSON(data []byte) error { 210 | var str string 211 | if err := json.Unmarshal(data, &str); err != nil { 212 | return err 213 | } 214 | switch strings.ToLower(str) { 215 | case "groupread": 216 | *t = GroupRead 217 | case "writeother": 218 | *t = WriteOther 219 | default: 220 | *t = GroupRead 221 | } 222 | return nil 223 | } 224 | 225 | // GroupAddressConfig defines all information to map a KNX group address to a prometheus metric. 226 | type GroupAddressConfig struct { 227 | // Name defines the prometheus metric name without the MetricsPrefix. 228 | Name string 229 | // Comment to identify the group address. 230 | Comment string `json:",omitempty"` 231 | // DPT defines the DPT at the knx bus. This is required to parse the values correctly. 232 | DPT string 233 | // MetricType is the type that prometheus uses when exporting it. i.e. gauge or counter 234 | MetricType string 235 | // Export the metric to prometheus 236 | Export bool 237 | // ReadStartup allows the exporter to actively send `GroupValueRead` telegrams to actively read the value at startup instead waiting for it. 238 | ReadStartup bool `json:",omitempty"` 239 | // ReadActive allows the exporter to actively send `GroupValueRead` telegrams to actively poll the value instead waiting for it. 240 | ReadActive bool `json:",omitempty"` 241 | // ReadType defines the type how to trigger the read request. Possible Values are GroupRead and WriteOther. 242 | ReadType ReadType `json:",omitempty"` 243 | // ReadAddress defines the group address to which address a GroupWrite request should be sent to initiate sending the data if ReadType is set to WriteOther. 244 | ReadAddress GroupAddress `json:",omitempty"` 245 | // ReadBody is a byte array with the content to sent to ReadAddress if ReadType is set to WriteOther. 246 | ReadBody []byte `json:",omitempty"` 247 | // MaxAge of a value until it will actively send a `GroupValueRead` telegram to read the value if ReadActive is set to true. 248 | MaxAge Duration `json:",omitempty"` 249 | // Labels defines static labels that should be set when exporting the metric using prometheus. 250 | Labels map[string]string `json:",omitempty"` 251 | // WithTimestamp defines if the exported metric should include the timestamp of receiving the last value. 252 | WithTimestamp bool 253 | } 254 | 255 | // GroupAddressConfigSet is a shortcut type for the group address config map. 256 | type GroupAddressConfigSet map[GroupAddress]*GroupAddressConfig 257 | -------------------------------------------------------------------------------- /pkg/knx/config_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package knx 16 | 17 | import ( 18 | "net" 19 | "testing" 20 | "time" 21 | 22 | "github.com/stretchr/testify/assert" 23 | "github.com/vapourismo/knx-go/knx" 24 | "github.com/vapourismo/knx-go/knx/cemi" 25 | ) 26 | 27 | func TestReadConfig(t *testing.T) { 28 | tests := []struct { 29 | name string 30 | configFile string 31 | config *Config 32 | wantErr bool 33 | }{ 34 | {"wrong filename", "fixtures/invalid.yaml", nil, true}, 35 | {"full config", "fixtures/full-config.yaml", &Config{ 36 | Connection: Connection{ 37 | Type: Tunnel, 38 | Endpoint: "192.168.1.15:3671", 39 | PhysicalAddress: PhysicalAddress(cemi.NewIndividualAddr3(2, 0, 1)), 40 | RouterConfig: RouterConfig{ 41 | RetainCount: 32, 42 | MulticastLoopbackEnabled: false, 43 | PostSendPauseDuration: 20 * time.Millisecond, 44 | }, 45 | TunnelConfig: TunnelConfig{ 46 | ResendInterval: 500 * time.Millisecond, 47 | HeartbeatInterval: 10 * time.Second, 48 | ResponseTimeout: 10 * time.Second, 49 | SendLocalAddress: false, 50 | UseTCP: false, 51 | }, 52 | }, 53 | MetricsPrefix: "knx_", 54 | AddressConfigs: map[GroupAddress]*GroupAddressConfig{ 55 | 1: { 56 | Name: "dummy_metric", 57 | DPT: "1.*", 58 | MetricType: "counter", 59 | Export: true, 60 | ReadActive: true, 61 | MaxAge: Duration(10 * time.Minute), 62 | ReadStartup: true, 63 | ReadType: WriteOther, 64 | ReadAddress: 2, 65 | ReadBody: []byte{1}, 66 | }, 67 | }, 68 | }, false}, 69 | {"converted config", "fixtures/ga-config.yaml", nil, true}, 70 | } 71 | for _, tt := range tests { 72 | t.Run(tt.name, func(t *testing.T) { 73 | config, err := ReadConfig(tt.configFile) 74 | if (err != nil) != tt.wantErr { 75 | t.Errorf("ReadConfig() error = %v, wantErr %v", err, tt.wantErr) 76 | return 77 | } 78 | assert.Equal(t, tt.config, config) 79 | }) 80 | } 81 | } 82 | 83 | func TestConfig_NameForGa(t *testing.T) { 84 | tests := []struct { 85 | name string 86 | address GroupAddress 87 | MetricsPrefix string 88 | AddressConfigs GroupAddressConfigSet 89 | want string 90 | }{ 91 | {"not found", GroupAddress(1), "knx_", GroupAddressConfigSet{}, ""}, 92 | {"ok", GroupAddress(1), "knx_", GroupAddressConfigSet{1: {Name: "dummy"}}, "knx_dummy"}, 93 | } 94 | for _, tt := range tests { 95 | t.Run(tt.name, func(t *testing.T) { 96 | c := &Config{ 97 | MetricsPrefix: tt.MetricsPrefix, 98 | AddressConfigs: tt.AddressConfigs, 99 | } 100 | got := c.NameForGa(tt.address) 101 | assert.Equal(t, tt.want, got) 102 | }) 103 | } 104 | } 105 | 106 | func TestTunnelConfig_toKnxTunnelConfig(t *testing.T) { 107 | tests := []struct { 108 | name string 109 | ResendInterval time.Duration 110 | HeartbeatInterval time.Duration 111 | ResponseTimeout time.Duration 112 | SendLocalAddress bool 113 | UseTCP bool 114 | want knx.TunnelConfig 115 | }{ 116 | {"default config", 117 | 500 * time.Millisecond, 118 | 10 * time.Second, 119 | 10 * time.Second, 120 | false, 121 | false, 122 | knx.DefaultTunnelConfig, 123 | }, 124 | } 125 | for _, tt := range tests { 126 | t.Run(tt.name, func(t *testing.T) { 127 | tc := TunnelConfig{ 128 | ResendInterval: tt.ResendInterval, 129 | HeartbeatInterval: tt.HeartbeatInterval, 130 | ResponseTimeout: tt.ResponseTimeout, 131 | SendLocalAddress: tt.SendLocalAddress, 132 | UseTCP: tt.UseTCP, 133 | } 134 | assert.Equalf(t, tt.want, tc.toKnxTunnelConfig(), "toKnxTunnelConfig()") 135 | }) 136 | } 137 | } 138 | 139 | func TestRouterConfig_toKnxRouterConfig(t *testing.T) { 140 | iface, err := net.InterfaceByIndex(1) 141 | assert.NoError(t, err) 142 | tests := []struct { 143 | name string 144 | RetainCount uint 145 | Interface string 146 | MulticastLoopbackEnabled bool 147 | PostSendPauseDuration time.Duration 148 | want knx.RouterConfig 149 | wantErr assert.ErrorAssertionFunc 150 | }{ 151 | { 152 | "default config", 153 | 32, 154 | "", 155 | false, 156 | 20 * time.Millisecond, 157 | knx.DefaultRouterConfig, 158 | assert.NoError, 159 | }, 160 | { 161 | "wrong interface", 162 | 32, 163 | "non existing interface", 164 | false, 165 | 20 * time.Millisecond, 166 | knx.RouterConfig{}, 167 | func(t assert.TestingT, err error, i ...interface{}) bool { 168 | return assert.NotNil(t, err, i) 169 | }, 170 | }, 171 | { 172 | "local interface", 173 | 32, 174 | iface.Name, 175 | false, 176 | 20 * time.Millisecond, 177 | knx.RouterConfig{ 178 | RetainCount: 32, 179 | Interface: iface, 180 | PostSendPauseDuration: 20 * time.Millisecond, 181 | }, 182 | assert.NoError, 183 | }, 184 | } 185 | for _, tt := range tests { 186 | t.Run(tt.name, func(t *testing.T) { 187 | rc := RouterConfig{ 188 | RetainCount: tt.RetainCount, 189 | Interface: tt.Interface, 190 | MulticastLoopbackEnabled: tt.MulticastLoopbackEnabled, 191 | PostSendPauseDuration: tt.PostSendPauseDuration, 192 | } 193 | got, err := rc.toKnxRouterConfig() 194 | if !tt.wantErr(t, err, "toKnxRouterConfig()") { 195 | return 196 | } 197 | assert.Equalf(t, tt.want, got, "toKnxRouterConfig()") 198 | }) 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /pkg/knx/converter.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package knx 16 | 17 | import ( 18 | "encoding/xml" 19 | "fmt" 20 | "log/slog" 21 | "os" 22 | "regexp" 23 | "strings" 24 | 25 | "github.com/chr-fritz/knx-exporter/pkg/knx/export" 26 | "github.com/chr-fritz/knx-exporter/pkg/utils" 27 | "github.com/ghodss/yaml" 28 | ) 29 | 30 | func ConvertGroupAddresses(src string, target string) error { 31 | addressExport, err := parseExport(src) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | groupAddresses := collectGroupAddresses(addressExport.GroupRange) 37 | 38 | addressConfigs := convertAddresses(groupAddresses) 39 | cfg := Config{ 40 | AddressConfigs: addressConfigs, 41 | MetricsPrefix: "knx_", 42 | } 43 | 44 | return writeConfig(cfg, target) 45 | } 46 | 47 | func writeConfig(cfg Config, target string) error { 48 | data, err := yaml.Marshal(cfg) 49 | if err != nil { 50 | return fmt.Errorf("can not marshal config: %s", err) 51 | } 52 | targetFile, err := os.Create(target) 53 | if err != nil { 54 | return fmt.Errorf("can not create file %s: %s", target, err) 55 | } 56 | defer utils.Close(targetFile) 57 | 58 | _, err = targetFile.Write(data) 59 | if err != nil { 60 | return fmt.Errorf("can not write config into %s: %s", target, err) 61 | } 62 | return nil 63 | } 64 | 65 | func parseExport(src string) (export.GroupAddressExport, error) { 66 | source, err := os.Open(src) 67 | if err != nil { 68 | return export.GroupAddressExport{}, fmt.Errorf("can not open source file '%s': %s", src, err) 69 | } 70 | defer utils.Close(source) 71 | 72 | decoder := xml.NewDecoder(source) 73 | addressExport := export.GroupAddressExport{} 74 | err = decoder.Decode(&addressExport) 75 | if err != nil { 76 | return export.GroupAddressExport{}, fmt.Errorf("can not parse group address export: %s", err) 77 | } 78 | return addressExport, nil 79 | } 80 | 81 | func collectGroupAddresses(groupRange []export.GroupRange) []export.GroupAddress { 82 | var addresses []export.GroupAddress 83 | 84 | for _, gr := range groupRange { 85 | addresses = append(addresses, gr.GroupAddress...) 86 | addresses = append(addresses, collectGroupAddresses(gr.GroupRange)...) 87 | } 88 | 89 | return addresses 90 | } 91 | 92 | func convertAddresses(groupAddresses []export.GroupAddress) map[GroupAddress]*GroupAddressConfig { 93 | addressConfigs := make(map[GroupAddress]*GroupAddressConfig) 94 | for _, ga := range groupAddresses { 95 | logger := slog.With("address", ga.Address) 96 | address, err := NewGroupAddress(ga.Address) 97 | if err != nil { 98 | logger.Warn("Can not convert address: " + err.Error()) 99 | continue 100 | } 101 | 102 | name, err := normalizeMetricName(ga.Name) 103 | if err != nil { 104 | logger.Info("Can not normalize group address name: " + err.Error()) 105 | } 106 | dpt, err := normalizeDPTs(ga.DPTs) 107 | if err != nil { 108 | logger.Info("Can not normalize data type: " + err.Error()) 109 | } 110 | cfg := &GroupAddressConfig{ 111 | Name: name, 112 | Comment: ga.Name + "\n" + ga.Description, 113 | DPT: dpt, 114 | MetricType: "", 115 | Export: false, 116 | ReadActive: false, 117 | MaxAge: 0, 118 | } 119 | addressConfigs[address] = cfg 120 | } 121 | return addressConfigs 122 | } 123 | 124 | var validMetricRegex = regexp.MustCompilePOSIX("^[a-zA-Z_:][a-zA-Z0-9_:]*$") 125 | var replaceMetricRegex = regexp.MustCompilePOSIX("[^a-zA-Z0-9_:]") 126 | var latin1Replacer = strings.NewReplacer("Ä", "Ae", "Ü", "Ue", "Ö", "Oe", "ä", "ae", "ü", "ue", "ö", "oe", "ß", "ss") 127 | 128 | func normalizeMetricName(name string) (string, error) { 129 | if validMetricRegex.MatchString(name) { 130 | return name, nil 131 | } 132 | 133 | normalized := latin1Replacer.Replace(name) 134 | if validMetricRegex.MatchString(normalized) { 135 | return normalized, nil 136 | } 137 | normalized = replaceMetricRegex.ReplaceAllLiteralString(normalized, "_") 138 | if !validMetricRegex.MatchString(normalized) { 139 | return "", fmt.Errorf("the group address name \"%s\" don't matchs the following regex: [a-zA-Z_:][a-zA-Z0-9_:]*", name) 140 | } 141 | return normalized, nil 142 | } 143 | 144 | var dptRegex = regexp.MustCompilePOSIX("(DPT|DPST)-([0-9]{1,2})(-([0-9]{1,3}))?") 145 | 146 | func normalizeDPTs(dpt string) (string, error) { 147 | if !dptRegex.MatchString(dpt) { 148 | return "", fmt.Errorf("data type \"%s\" is not a valid knx type", dpt) 149 | } 150 | matches := dptRegex.FindStringSubmatch(dpt) 151 | 152 | if len(matches) != 5 { 153 | return "", fmt.Errorf("invalid match found") 154 | } 155 | if matches[4] == "" { 156 | return fmt.Sprintf("%s.*", matches[2]), nil 157 | } 158 | return fmt.Sprintf("%s.%03s", matches[2], matches[4]), nil 159 | 160 | } 161 | -------------------------------------------------------------------------------- /pkg/knx/converter_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package knx 16 | 17 | import ( 18 | "os" 19 | "testing" 20 | 21 | "github.com/ghodss/yaml" 22 | "github.com/stretchr/testify/assert" 23 | ) 24 | 25 | func TestConvertGroupAddresses(t *testing.T) { 26 | tests := []struct { 27 | name string 28 | src string 29 | wantTarget string 30 | wantErr bool 31 | }{ 32 | {"full", "fixtures/ga-export.xml", "fixtures/ga-config.yaml", false}, 33 | {"source do not exists", "fixtures/invalid.xml", "", true}, 34 | } 35 | for _, tt := range tests { 36 | t.Run(tt.name, func(t *testing.T) { 37 | tmpFile, err := os.CreateTemp("", "") 38 | assert.NoError(t, err) 39 | defer func() { 40 | _ = os.Remove(tmpFile.Name()) 41 | }() 42 | 43 | if err := ConvertGroupAddresses(tt.src, tmpFile.Name()); (err != nil) != tt.wantErr { 44 | t.Errorf("ConvertGroupAddresses() error = %v, wantErr %v", err, tt.wantErr) 45 | return 46 | } 47 | if tt.wantErr { 48 | return 49 | } 50 | 51 | assert.FileExists(t, tmpFile.Name()) 52 | 53 | expected, err := os.ReadFile(tt.wantTarget) 54 | assert.NoError(t, err) 55 | expected, err = yaml.YAMLToJSON(expected) 56 | assert.NoError(t, err) 57 | actual, err := os.ReadFile(tmpFile.Name()) 58 | assert.NoError(t, err) 59 | actual, err = yaml.YAMLToJSON(actual) 60 | assert.NoError(t, err) 61 | 62 | assert.JSONEq(t, string(expected), string(actual)) 63 | }) 64 | } 65 | } 66 | 67 | func Test_normalizeMetricName(t *testing.T) { 68 | tests := []struct { 69 | name string 70 | want string 71 | wantErr bool 72 | }{ 73 | {"is_valid_regex", "is_valid_regex", false}, 74 | {"Is_1_valid_regex", "Is_1_valid_regex", false}, 75 | {"eine gültige ga", "eine_gueltige_ga", false}, 76 | {"ÄÜÖäüöß", "AeUeOeaeueoess", false}, 77 | {"6_asdf", "", true}, 78 | } 79 | for _, tt := range tests { 80 | t.Run(tt.name, func(t *testing.T) { 81 | got, err := normalizeMetricName(tt.name) 82 | if (err != nil) != tt.wantErr { 83 | t.Errorf("normalizeMetricName() error = %v, wantErr %v", err, tt.wantErr) 84 | return 85 | } 86 | assert.Equal(t, tt.want, got) 87 | }) 88 | } 89 | } 90 | 91 | func Test_normalizeDPTs(t *testing.T) { 92 | tests := []struct { 93 | name string 94 | dpt string 95 | want string 96 | wantErr bool 97 | }{ 98 | {"DPST-1-1", "DPST-1-1", "1.001", false}, 99 | {"DPST-1-5", "DPST-1-5", "1.005", false}, 100 | {"DPST-1-7", "DPST-1-7", "1.007", false}, 101 | {"DPST-1-8", "DPST-1-8", "1.008", false}, 102 | {"DPST-20-102", "DPST-20-102", "20.102", false}, 103 | {"DPST-3-7", "DPST-3-7", "3.007", false}, 104 | {"DPST-5-1", "DPST-5-1", "5.001", false}, 105 | {"DPST-7-7", "DPST-7-7", "7.007", false}, 106 | {"DPST-9-1", "DPST-9-1", "9.001", false}, 107 | {"DPT-1", "DPT-1", "1.*", false}, 108 | {"DPT-13", "DPT-13", "13.*", false}, 109 | {"DPT-5", "DPT-5", "5.*", false}, 110 | {"DPT-7", "DPT-7", "7.*", false}, 111 | {"DPT-9", "DPT-9", "9.*", false}, 112 | {"invalid", "DPT9", "", true}, 113 | } 114 | for _, tt := range tests { 115 | t.Run(tt.name, func(t *testing.T) { 116 | got, err := normalizeDPTs(tt.dpt) 117 | if (err != nil) != tt.wantErr { 118 | t.Errorf("normalizeDPTs() error = %v, wantErr %v", err, tt.wantErr) 119 | return 120 | } 121 | assert.Equal(t, tt.want, got) 122 | }) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /pkg/knx/export/types.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package export 16 | 17 | import ( 18 | "encoding/xml" 19 | ) 20 | 21 | // GroupAddressExport is the root element of the xml file which generates the ETS 5 application 22 | // while the group address export. 23 | type GroupAddressExport struct { 24 | XMLName xml.Name `xml:"GroupAddress-Export"` 25 | Xmlns string `xml:"xmlns,attr"` 26 | GroupRange []GroupRange `xml:"GroupRange"` 27 | } 28 | 29 | // GroupRange defines a rage of group addresses in the ETS 5 group address export. 30 | type GroupRange struct { 31 | Name string `xml:"Name,attr"` 32 | RangeStart uint16 `xml:"RangeStart,attr"` 33 | RangeEnd uint16 `xml:"RangeEnd,attr"` 34 | GroupRange []GroupRange `xml:"GroupRange"` 35 | GroupAddress []GroupAddress `xml:"GroupAddress"` 36 | } 37 | 38 | // GroupAddress defines a a single group address in the ETS 5 group address export. 39 | type GroupAddress struct { 40 | Name string `xml:"Name,attr"` 41 | Address string `xml:"Address,attr"` 42 | Central bool `xml:"Central,attr"` 43 | Unfiltered bool `xml:"Unfiltered,attr"` 44 | DPTs string `xml:"DPTs,attr"` 45 | Description string `xml:"Description,attr"` 46 | } 47 | -------------------------------------------------------------------------------- /pkg/knx/exporter.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package knx 16 | 17 | //go:generate mockgen -destination=fake/exporterMocks.go -package=fake -source=exporter.go 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "log/slog" 23 | 24 | "github.com/prometheus/client_golang/prometheus" 25 | "github.com/vapourismo/knx-go/knx" 26 | ) 27 | 28 | type MetricsExporter interface { 29 | Run(ctx context.Context) error 30 | IsAlive() error 31 | } 32 | 33 | type metricsExporter struct { 34 | config *Config 35 | client GroupClient 36 | 37 | metrics MetricSnapshotHandler 38 | listener Listener 39 | messageCounter *prometheus.CounterVec 40 | poller Poller 41 | health error 42 | } 43 | 44 | func NewMetricsExporter(configFile string, registerer prometheus.Registerer) (MetricsExporter, error) { 45 | config, err := ReadConfig(configFile) 46 | if err != nil { 47 | return nil, err 48 | } 49 | m := &metricsExporter{ 50 | config: config, 51 | metrics: NewMetricsSnapshotHandler(), 52 | messageCounter: prometheus.NewCounterVec(prometheus.CounterOpts{ 53 | Name: "messages", 54 | Namespace: "knx", 55 | }, []string{"direction", "processed"}), 56 | } 57 | if err = registerer.Register(m.messageCounter); err != nil { 58 | return nil, fmt.Errorf("can not register message counter metrics: %s", err) 59 | } 60 | if err = registerer.Register(m.metrics); err != nil { 61 | return nil, fmt.Errorf("can not register metrics collector: %s", err) 62 | } 63 | return m, nil 64 | } 65 | 66 | func (e *metricsExporter) Run(ctx context.Context) error { 67 | e.poller = NewPoller(e.config, e.metrics, e.messageCounter) 68 | e.listener = NewListener(e.config, e.metrics.GetMetricsChannel(), e.messageCounter) 69 | go e.metrics.Run(ctx) 70 | 71 | if err := e.createClient(); err != nil { 72 | e.health = err 73 | return err 74 | } 75 | context.AfterFunc(ctx, func() { 76 | e.client.Close() 77 | }) 78 | 79 | go e.listener.Run(ctx, e.client.Inbound()) 80 | e.poller.Run(ctx, e.client, true) 81 | 82 | return nil 83 | } 84 | 85 | func (e *metricsExporter) IsAlive() error { 86 | if !e.listener.IsActive() { 87 | return fmt.Errorf("listener is closed") 88 | } 89 | if !e.metrics.IsActive() { 90 | return fmt.Errorf("metric snapshot handler is closed") 91 | } 92 | 93 | return e.health 94 | } 95 | 96 | func (e *metricsExporter) createClient() error { 97 | switch e.config.Connection.Type { 98 | case Tunnel: 99 | slog.With( 100 | "endpoint", e.config.Connection.Endpoint, 101 | "connection_type", "tunnel", 102 | "useTcp", e.config.Connection.TunnelConfig.UseTCP, 103 | ).Info("Connecting to endpoint") 104 | tunnel, err := knx.NewGroupTunnel(e.config.Connection.Endpoint, e.config.Connection.TunnelConfig.toKnxTunnelConfig()) 105 | if err != nil { 106 | return err 107 | } 108 | e.client = &tunnel 109 | return nil 110 | case Router: 111 | slog.With( 112 | "endpoint", e.config.Connection.Endpoint, 113 | "connection_type", "routing", 114 | ).Info("Connecting to endpoint") 115 | 116 | config, err := e.config.Connection.RouterConfig.toKnxRouterConfig() 117 | if err != nil { 118 | return fmt.Errorf("unable to convert router config: %s", err) 119 | } 120 | 121 | router, err := knx.NewGroupRouter(e.config.Connection.Endpoint, config) 122 | if err != nil { 123 | return err 124 | } 125 | e.client = &router 126 | return nil 127 | default: 128 | return fmt.Errorf("invalid connection type. must be either Tunnel or Router") 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /pkg/knx/exporter_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package knx 16 | 17 | import ( 18 | "context" 19 | "testing" 20 | "time" 21 | 22 | "github.com/golang/mock/gomock" 23 | "github.com/stretchr/testify/assert" 24 | 25 | "github.com/chr-fritz/knx-exporter/pkg/metrics/fake" 26 | ) 27 | 28 | func TestNewMetricsExporter(t *testing.T) { 29 | ctrl := gomock.NewController(t) 30 | defer ctrl.Finish() 31 | ctx, cancelFunc := context.WithCancel(context.TODO()) 32 | 33 | exporter := fake.NewMockExporter(ctrl) 34 | exporter.EXPECT().Register(gomock.Any()).AnyTimes() 35 | exp, err := NewMetricsExporter("fixtures/readConfig.yaml", exporter) 36 | metricsExporter, ok := exp.(*metricsExporter) 37 | assert.True(t, ok) 38 | assert.NoError(t, err) 39 | 40 | err = metricsExporter.Run(ctx) 41 | assert.NoError(t, err) 42 | 43 | assert.NotNil(t, metricsExporter.metrics) 44 | assert.NotNil(t, metricsExporter.poller) 45 | assert.NotNil(t, metricsExporter.listener) 46 | 47 | time.Sleep(1 * time.Second) 48 | cancelFunc() 49 | } 50 | 51 | func TestMetricsExporter_createClient(t *testing.T) { 52 | tests := []struct { 53 | name string 54 | config *Config 55 | wantErr bool 56 | }{ 57 | {"wrong-type", &Config{Connection: Connection{Type: ConnectionType("wrong")}}, true}, 58 | {"tunnel", &Config{Connection: Connection{Type: Tunnel, Endpoint: "127.0.0.1:3761"}}, true}, 59 | {"router", &Config{Connection: Connection{Type: Router, Endpoint: "224.0.0.120:3672"}}, false}, 60 | } 61 | for _, tt := range tests { 62 | t.Run(tt.name, func(t *testing.T) { 63 | e := &metricsExporter{ 64 | config: tt.config, 65 | } 66 | if err := e.createClient(); (err != nil) != tt.wantErr { 67 | t.Errorf("createClient() error = %v, wantErr %v", err, tt.wantErr) 68 | } 69 | }) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /pkg/knx/fake/exporterMocks.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2022-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Code generated by MockGen. DO NOT EDIT. 16 | // Source: exporter.go 17 | 18 | // Package fake is a generated GoMock package. 19 | package fake 20 | 21 | import ( 22 | context "context" 23 | reflect "reflect" 24 | 25 | gomock "github.com/golang/mock/gomock" 26 | ) 27 | 28 | // MockMetricsExporter is a mock of MetricsExporter interface. 29 | type MockMetricsExporter struct { 30 | ctrl *gomock.Controller 31 | recorder *MockMetricsExporterMockRecorder 32 | } 33 | 34 | // MockMetricsExporterMockRecorder is the mock recorder for MockMetricsExporter. 35 | type MockMetricsExporterMockRecorder struct { 36 | mock *MockMetricsExporter 37 | } 38 | 39 | // NewMockMetricsExporter creates a new mock instance. 40 | func NewMockMetricsExporter(ctrl *gomock.Controller) *MockMetricsExporter { 41 | mock := &MockMetricsExporter{ctrl: ctrl} 42 | mock.recorder = &MockMetricsExporterMockRecorder{mock} 43 | return mock 44 | } 45 | 46 | // EXPECT returns an object that allows the caller to indicate expected use. 47 | func (m *MockMetricsExporter) EXPECT() *MockMetricsExporterMockRecorder { 48 | return m.recorder 49 | } 50 | 51 | // IsAlive mocks base method. 52 | func (m *MockMetricsExporter) IsAlive() error { 53 | m.ctrl.T.Helper() 54 | ret := m.ctrl.Call(m, "IsAlive") 55 | ret0, _ := ret[0].(error) 56 | return ret0 57 | } 58 | 59 | // IsAlive indicates an expected call of IsAlive. 60 | func (mr *MockMetricsExporterMockRecorder) IsAlive() *gomock.Call { 61 | mr.mock.ctrl.T.Helper() 62 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsAlive", reflect.TypeOf((*MockMetricsExporter)(nil).IsAlive)) 63 | } 64 | 65 | // Run mocks base method. 66 | func (m *MockMetricsExporter) Run(ctx context.Context) error { 67 | m.ctrl.T.Helper() 68 | ret := m.ctrl.Call(m, "Run", ctx) 69 | ret0, _ := ret[0].(error) 70 | return ret0 71 | } 72 | 73 | // Run indicates an expected call of Run. 74 | func (mr *MockMetricsExporterMockRecorder) Run(ctx interface{}) *gomock.Call { 75 | mr.mock.ctrl.T.Helper() 76 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Run", reflect.TypeOf((*MockMetricsExporter)(nil).Run), ctx) 77 | } 78 | -------------------------------------------------------------------------------- /pkg/knx/fixtures/full-config.yaml: -------------------------------------------------------------------------------- 1 | Connection: 2 | Type: "Tunnel" 3 | Endpoint: "192.168.1.15:3671" 4 | PhysicalAddress: 2.0.1 5 | RouterConfig: 6 | Interface: "" 7 | MulticastLoopbackEnabled: false 8 | PostSendPauseDuration: 20000000 9 | RetainCount: 32 10 | TunnelConfig: 11 | HeartbeatInterval: 10000000000 12 | ResendInterval: 500000000 13 | ResponseTimeout: 10000000000 14 | SendLocalAddress: false 15 | UseTCP: false 16 | MetricsPrefix: knx_ 17 | AddressConfigs: 18 | 0/0/1: 19 | Name: dummy_metric 20 | DPT: 1.* 21 | Export: true 22 | MetricType: "counter" 23 | ReadActive: true 24 | MaxAge: 10m 25 | ReadStartup: true 26 | ReadType: WriteOther 27 | ReadAddress: 0/0/2 28 | ReadBody: [ 0x1 ] 29 | -------------------------------------------------------------------------------- /pkg/knx/fixtures/ga-config.yaml: -------------------------------------------------------------------------------- 1 | Connection: 2 | Endpoint: "" 3 | Type: "" 4 | PhysicalAddress: 0.0.0 5 | RouterConfig: 6 | Interface: "" 7 | MulticastLoopbackEnabled: false 8 | PostSendPauseDuration: 0 9 | RetainCount: 0 10 | TunnelConfig: 11 | HeartbeatInterval: 0 12 | ResendInterval: 0 13 | ResponseTimeout: 0 14 | SendLocalAddress: false 15 | UseTCP: false 16 | MetricsPrefix: knx_ 17 | AddressConfigs: 18 | 0/0/1: 19 | Name: AAA 20 | Comment: | 21 | AAA 22 | DPT: "1.*" 23 | Export: false 24 | MetricType: "" 25 | WithTimestamp: false 26 | 0/1/0: 27 | Name: ABAA 28 | Comment: |- 29 | ABAA 30 | AAB 31 | DPT: "1.001" 32 | Export: false 33 | MetricType: "" 34 | WithTimestamp: false 35 | 0/1/2: 36 | Name: CC 37 | Comment: |- 38 | CC 39 | CC 40 | DPT: "1.001" 41 | Export: false 42 | MetricType: "" 43 | WithTimestamp: false 44 | 0/1/3: 45 | Name: CC 46 | Comment: |- 47 | CC 48 | CD 49 | DPT: "1.001" 50 | Export: false 51 | MetricType: "" 52 | WithTimestamp: false 53 | -------------------------------------------------------------------------------- /pkg/knx/fixtures/ga-export.xml: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /pkg/knx/fixtures/readConfig.yaml: -------------------------------------------------------------------------------- 1 | Connection: 2 | Type: "Router" 3 | Endpoint: "224.0.0.120:3672" 4 | PhysicalAddress: 2.0.1 5 | RouterConfig: 6 | Interface: "" 7 | MulticastLoopbackEnabled: false 8 | PostSendPauseDuration: 20000000 9 | RetainCount: 32 10 | TunnelConfig: 11 | HeartbeatInterval: 10000000000 12 | ResendInterval: 500000000 13 | ResponseTimeout: 10000000000 14 | SendLocalAddress: false 15 | UseTCP: false 16 | MetricsPrefix: knx_ 17 | ReadStartupInterval: 250ms 18 | AddressConfigs: 19 | 0/0/1: 20 | Name: dummy_metric 21 | DPT: 1.* 22 | Export: true 23 | MetricType: "counter" 24 | ReadStartup: true 25 | ReadActive: true 26 | MaxAge: 5s 27 | 0/0/2: 28 | Name: dummy_metric1 29 | DPT: 1.* 30 | Export: true 31 | MetricType: "counter" 32 | ReadStartup: true 33 | ReadActive: true 34 | MaxAge: 5s 35 | 0/0/3: 36 | Name: dummy_metric2 37 | DPT: 1.* 38 | Export: true 39 | MetricType: "counter" 40 | ReadStartup: true 41 | ReadActive: true 42 | MaxAge: 5s 43 | 0/0/4: 44 | Name: dummy_metric3 45 | DPT: 1.* 46 | Export: true 47 | MetricType: "counter" 48 | ReadStartup: false 49 | ReadActive: false 50 | MaxAge: 5s 51 | 0/0/5: 52 | Name: dummy_metric4 53 | DPT: 1.* 54 | Export: true 55 | MetricType: "counter" 56 | ReadStartup: true 57 | ReadActive: true 58 | MaxAge: 5s 59 | ReadType: WriteOther 60 | ReadAddress: 0/0/6 61 | ReadBody: [ 0x1 ] 62 | -------------------------------------------------------------------------------- /pkg/knx/group-address.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package knx 16 | 17 | import ( 18 | "encoding/json" 19 | "strings" 20 | 21 | "github.com/vapourismo/knx-go/knx/cemi" 22 | ) 23 | 24 | // GroupAddress defines a single group address. It do not contain any additional information about purpose, data types 25 | // or allowed telegram types. 26 | type GroupAddress cemi.GroupAddr 27 | 28 | // InvalidGroupAddress defines the nil group address. 29 | const InvalidGroupAddress = GroupAddress(0) 30 | 31 | // NewGroupAddress creates a new GroupAddress by parsing the given string. It either returns the parsed GroupAddress or 32 | // an error if it is not possible to parse the string. 33 | func NewGroupAddress(str string) (GroupAddress, error) { 34 | ga, e := cemi.NewGroupAddrString(str) 35 | if e != nil { 36 | return InvalidGroupAddress, e 37 | } 38 | return GroupAddress(ga), nil 39 | } 40 | 41 | func (g GroupAddress) String() string { 42 | return cemi.GroupAddr(g).String() 43 | } 44 | 45 | func (g GroupAddress) MarshalJSON() ([]byte, error) { 46 | return json.Marshal(g.String()) 47 | } 48 | 49 | func (g GroupAddress) MarshalText() ([]byte, error) { 50 | return []byte(g.String()), nil 51 | } 52 | 53 | func (g *GroupAddress) UnmarshalJSON(data []byte) error { 54 | str := string(data) 55 | str = strings.Trim(str, "\"'") 56 | ga, e := cemi.NewGroupAddrString(str) 57 | if e != nil { 58 | return e 59 | } 60 | *g = GroupAddress(ga) 61 | return nil 62 | } 63 | func (g *GroupAddress) UnmarshalText(data []byte) error { 64 | return g.UnmarshalJSON(data) 65 | } 66 | -------------------------------------------------------------------------------- /pkg/knx/group-address_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package knx 16 | 17 | import ( 18 | "testing" 19 | 20 | "github.com/stretchr/testify/assert" 21 | ) 22 | 23 | func TestGroupAddress_MarshalJSON(t *testing.T) { 24 | tests := []struct { 25 | name string 26 | g GroupAddress 27 | want []byte 28 | wantErr bool 29 | }{ 30 | {"0/0/0", GroupAddress(0), []byte("\"0/0/0\""), false}, 31 | {"0/0/1", GroupAddress(1), []byte("\"0/0/1\""), false}, 32 | {"0/1/0", GroupAddress(0x100), []byte("\"0/1/0\""), false}, 33 | {"31/7/0", GroupAddress(0xFF00), []byte("\"31/7/0\""), false}, 34 | } 35 | for _, tt := range tests { 36 | t.Run(tt.name, func(t *testing.T) { 37 | got, err := tt.g.MarshalJSON() 38 | if (err != nil) != tt.wantErr { 39 | t.Errorf("MarshalJSON() error = %v, wantErr %v", err, tt.wantErr) 40 | return 41 | } 42 | assert.Equal(t, tt.want, got) 43 | }) 44 | } 45 | } 46 | 47 | func TestGroupAddress_MarshalText(t *testing.T) { 48 | tests := []struct { 49 | name string 50 | g GroupAddress 51 | want []byte 52 | wantErr bool 53 | }{ 54 | {"0/0/0", GroupAddress(0), []byte("0/0/0"), false}, 55 | {"0/0/1", GroupAddress(1), []byte("0/0/1"), false}, 56 | {"0/1/0", GroupAddress(0x100), []byte("0/1/0"), false}, 57 | {"31/7/0", GroupAddress(0xFF00), []byte("31/7/0"), false}, 58 | } 59 | for _, tt := range tests { 60 | t.Run(tt.name, func(t *testing.T) { 61 | got, err := tt.g.MarshalText() 62 | if (err != nil) != tt.wantErr { 63 | t.Errorf("MarshalText() error = %v, wantErr %v", err, tt.wantErr) 64 | return 65 | } 66 | assert.Equal(t, tt.want, got) 67 | }) 68 | } 69 | } 70 | 71 | func TestGroupAddress_String(t *testing.T) { 72 | tests := []struct { 73 | name string 74 | g GroupAddress 75 | want string 76 | }{ 77 | {"0/0/0", GroupAddress(0), "0/0/0"}, 78 | {"0/0/1", GroupAddress(1), "0/0/1"}, 79 | {"0/1/0", GroupAddress(0x100), "0/1/0"}, 80 | {"31/7/0", GroupAddress(0xFF00), "31/7/0"}, 81 | } 82 | for _, tt := range tests { 83 | t.Run(tt.name, func(t *testing.T) { 84 | got := tt.g.String() 85 | assert.Equal(t, tt.want, got) 86 | }) 87 | } 88 | } 89 | 90 | func TestGroupAddress_UnmarshalJSON(t *testing.T) { 91 | tests := []struct { 92 | name string 93 | data []byte 94 | want GroupAddress 95 | wantErr bool 96 | }{ 97 | {"0/0/0", []byte("0/0/0"), GroupAddress(0), true}, 98 | {"0/0/1", []byte("0/0/1"), GroupAddress(1), false}, 99 | {"0/1/0", []byte("0/1/0"), GroupAddress(0x100), false}, 100 | {"31/7/0", []byte("31/7/0"), GroupAddress(0xFF00), false}, 101 | {"a/b/c", []byte("a/b/c"), GroupAddress(0), true}, 102 | } 103 | for _, tt := range tests { 104 | t.Run(tt.name, func(t *testing.T) { 105 | g := GroupAddress(0) 106 | if err := g.UnmarshalJSON(tt.data); (err != nil) != tt.wantErr { 107 | t.Errorf("UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) 108 | return 109 | } 110 | assert.Equal(t, tt.want, g) 111 | }) 112 | } 113 | } 114 | 115 | func TestGroupAddress_UnmarshalText(t *testing.T) { 116 | tests := []struct { 117 | name string 118 | data []byte 119 | want GroupAddress 120 | wantErr bool 121 | }{ 122 | {"0/0/0", []byte("0/0/0"), GroupAddress(0), true}, 123 | {"0/0/1", []byte("0/0/1"), GroupAddress(1), false}, 124 | {"0/1/0", []byte("0/1/0"), GroupAddress(0x100), false}, 125 | {"31/7/0", []byte("31/7/0"), GroupAddress(0xFF00), false}, 126 | {"a/b/c", []byte("a/b/c"), GroupAddress(0), true}, 127 | } 128 | for _, tt := range tests { 129 | t.Run(tt.name, func(t *testing.T) { 130 | g := GroupAddress(0) 131 | if err := g.UnmarshalText(tt.data); (err != nil) != tt.wantErr { 132 | t.Errorf("UnmarshalText() error = %v, wantErr %v", err, tt.wantErr) 133 | return 134 | } 135 | assert.Equal(t, tt.want, g) 136 | }) 137 | } 138 | } 139 | 140 | func TestNewGroupAddress(t *testing.T) { 141 | tests := []struct { 142 | name string 143 | want GroupAddress 144 | wantErr bool 145 | }{ 146 | {"0/0/0", GroupAddress(0), true}, 147 | {"0/0/1", GroupAddress(1), false}, 148 | {"0/0/1", GroupAddress(1), false}, 149 | {"0/1/0", GroupAddress(0x100), false}, 150 | {"31/7/0", GroupAddress(0xFF00), false}, 151 | {"31/7", GroupAddress(0xf807), false}, 152 | {"31", GroupAddress(0x1f), false}, 153 | {"a/b/c", GroupAddress(0), true}, 154 | } 155 | for _, tt := range tests { 156 | t.Run(tt.name, func(t *testing.T) { 157 | got, err := NewGroupAddress(tt.name) 158 | if (err != nil) != tt.wantErr { 159 | t.Errorf("NewGroupAddress() error = %v, wantErr %v", err, tt.wantErr) 160 | return 161 | } 162 | assert.Equal(t, tt.want, got) 163 | }) 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /pkg/knx/listener.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package knx 16 | 17 | import ( 18 | "context" 19 | "fmt" 20 | "log/slog" 21 | "math" 22 | "reflect" 23 | "time" 24 | 25 | "github.com/prometheus/client_golang/prometheus" 26 | "github.com/vapourismo/knx-go/knx" 27 | "github.com/vapourismo/knx-go/knx/dpt" 28 | ) 29 | 30 | type Listener interface { 31 | Run(ctx context.Context, inbound <-chan knx.GroupEvent) 32 | IsActive() bool 33 | } 34 | 35 | type listener struct { 36 | config *Config 37 | metricsChan chan *Snapshot 38 | messageCounter *prometheus.CounterVec 39 | active bool 40 | logger *slog.Logger 41 | } 42 | 43 | func NewListener(config *Config, metricsChan chan *Snapshot, messageCounter *prometheus.CounterVec) Listener { 44 | return &listener{ 45 | config: config, 46 | metricsChan: metricsChan, 47 | messageCounter: messageCounter, 48 | active: true, 49 | logger: slog.With( 50 | "connectionType", config.Connection.Type, 51 | "endpoint", config.Connection.Endpoint, 52 | ), 53 | } 54 | } 55 | 56 | func (l *listener) Run(ctx context.Context, inbound <-chan knx.GroupEvent) { 57 | l.logger.Info("Waiting for incoming knx telegrams...") 58 | defer func() { 59 | l.active = false 60 | l.logger.Warn("Finished listening for incoming knx telegrams") 61 | ctx.Err() 62 | }() 63 | for { 64 | select { 65 | case msg := <-inbound: 66 | l.handleEvent(msg) 67 | case <-ctx.Done(): 68 | break 69 | } 70 | } 71 | } 72 | 73 | func (l *listener) IsActive() bool { 74 | return l.active 75 | } 76 | 77 | func (l *listener) handleEvent(event knx.GroupEvent) { 78 | l.messageCounter.WithLabelValues("received", "false").Inc() 79 | destination := GroupAddress(event.Destination) 80 | logger := l.logger.With( 81 | "command", event.Command.String(), 82 | "source", event.Source.String(), 83 | "destination", event.Destination.String(), 84 | ) 85 | 86 | addr, ok := l.config.AddressConfigs[destination] 87 | if !ok { 88 | logger.Debug("Received event but ignore them due to missing configuration") 89 | return 90 | } 91 | 92 | if event.Command == knx.GroupRead { 93 | logger.Debug("Skip group event as it is a GroupRead message.") 94 | return 95 | } 96 | 97 | value, err := unpackEvent(event, addr) 98 | logger = logger.With("dpt", addr.DPT) 99 | 100 | if err != nil { 101 | logger.Warn(err.Error()) 102 | return 103 | } 104 | 105 | floatValue, err := extractAsFloat64(value) 106 | if err != nil { 107 | logger.Warn(err.Error()) 108 | return 109 | } 110 | metricName := l.config.NameFor(addr) 111 | logger.With( 112 | "metricName", metricName, 113 | "value", value, 114 | ).Log(nil, slog.LevelDebug-2, "Processed received group address value") 115 | l.metricsChan <- &Snapshot{ 116 | name: metricName, 117 | value: floatValue, 118 | source: PhysicalAddress(event.Source), 119 | timestamp: time.Now(), 120 | config: addr, 121 | destination: destination, 122 | } 123 | l.messageCounter.WithLabelValues("received", "true").Inc() 124 | } 125 | 126 | func unpackEvent(event knx.GroupEvent, addr *GroupAddressConfig) (DPT, error) { 127 | v, found := dpt.Produce(addr.DPT) 128 | if !found { 129 | return nil, fmt.Errorf("can not find dpt description for \"%s\" to unpack %s telegram from %s for %s", 130 | addr.DPT, 131 | event.Command.String(), 132 | event.Source.String(), 133 | event.Destination.String()) 134 | 135 | } 136 | value := v.(DPT) 137 | 138 | if err := value.Unpack(event.Data); err != nil { 139 | return nil, fmt.Errorf("can not unpack data: %s", err) 140 | } 141 | return value, nil 142 | } 143 | 144 | func extractAsFloat64(value dpt.DatapointValue) (float64, error) { 145 | typedValue := reflect.ValueOf(value).Elem() 146 | kind := typedValue.Kind() 147 | if kind == reflect.Bool { 148 | if typedValue.Bool() { 149 | return 1, nil 150 | } else { 151 | return 0, nil 152 | } 153 | } else if kind >= reflect.Int && kind <= reflect.Int64 { 154 | return float64(typedValue.Int()), nil 155 | } else if kind >= reflect.Uint && kind <= reflect.Uint64 { 156 | return float64(typedValue.Uint()), nil 157 | } else if kind >= reflect.Float32 && kind <= reflect.Float64 { 158 | return typedValue.Float(), nil 159 | } else { 160 | return math.NaN(), fmt.Errorf("can not find appropriate type for %s", typedValue.Type().Name()) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /pkg/knx/listener_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package knx 16 | 17 | import ( 18 | "context" 19 | "testing" 20 | "time" 21 | 22 | "github.com/prometheus/client_golang/prometheus" 23 | "github.com/stretchr/testify/assert" 24 | "github.com/vapourismo/knx-go/knx" 25 | "github.com/vapourismo/knx-go/knx/cemi" 26 | ) 27 | 28 | func Test_listener_Run(t *testing.T) { 29 | tests := []struct { 30 | name string 31 | event knx.GroupEvent 32 | want *Snapshot 33 | wantError bool 34 | }{ 35 | { 36 | "bool false", 37 | knx.GroupEvent{Destination: cemi.GroupAddr(1), Command: knx.GroupWrite, Data: []byte{0}}, 38 | &Snapshot{name: "knx_a", value: 0, destination: GroupAddress(1), config: &GroupAddressConfig{Name: "a", DPT: "1.001", Export: true}}, 39 | false, 40 | }, 41 | { 42 | "bool true", 43 | knx.GroupEvent{Destination: cemi.GroupAddr(1), Command: knx.GroupWrite, Data: []byte{1}}, 44 | &Snapshot{name: "knx_a", value: 1, destination: GroupAddress(1), config: &GroupAddressConfig{Name: "a", DPT: "1.001", Export: true}}, 45 | false, 46 | }, { 47 | "bool false response", 48 | knx.GroupEvent{Destination: cemi.GroupAddr(1), Command: knx.GroupResponse, Data: []byte{0}}, 49 | &Snapshot{name: "knx_a", value: 0, destination: GroupAddress(1), config: &GroupAddressConfig{Name: "a", DPT: "1.001", Export: true}}, 50 | false, 51 | }, 52 | { 53 | "bool true response", 54 | knx.GroupEvent{Destination: cemi.GroupAddr(1), Command: knx.GroupResponse, Data: []byte{1}}, 55 | &Snapshot{name: "knx_a", value: 1, destination: GroupAddress(1), config: &GroupAddressConfig{Name: "a", DPT: "1.001", Export: true}}, 56 | false, 57 | }, 58 | { 59 | "5.*", 60 | knx.GroupEvent{Destination: cemi.GroupAddr(2), Command: knx.GroupWrite, Data: []byte{0, 255}}, 61 | &Snapshot{name: "knx_b", value: 100, destination: GroupAddress(2), config: &GroupAddressConfig{Name: "b", DPT: "5.001", Export: true}}, 62 | false, 63 | }, 64 | { 65 | "9.*", 66 | knx.GroupEvent{Destination: cemi.GroupAddr(3), Command: knx.GroupWrite, Data: []byte{0, 2, 38}}, 67 | &Snapshot{name: "knx_c", value: 5.5, destination: GroupAddress(3), config: &GroupAddressConfig{Name: "c", DPT: "9.001", Export: true}}, 68 | false, 69 | }, 70 | { 71 | "12.*", 72 | knx.GroupEvent{Destination: cemi.GroupAddr(4), Command: knx.GroupWrite, Data: []byte{0, 0, 0, 0, 5}}, 73 | &Snapshot{name: "knx_d", value: 5, destination: GroupAddress(4), config: &GroupAddressConfig{Name: "d", DPT: "12.001", Export: true}}, 74 | false, 75 | }, 76 | { 77 | "13.*", 78 | knx.GroupEvent{Destination: cemi.GroupAddr(5), Command: knx.GroupWrite, Data: []byte{0, 0, 0, 0, 5}}, 79 | &Snapshot{name: "knx_e", value: 5, destination: GroupAddress(5), config: &GroupAddressConfig{Name: "e", DPT: "13.001", Export: true}}, 80 | false, 81 | }, 82 | { 83 | "14.*", 84 | knx.GroupEvent{Destination: cemi.GroupAddr(6), Command: knx.GroupWrite, Data: []byte{0, 63, 192, 0, 0}}, 85 | &Snapshot{name: "knx_f", value: 1.5, destination: GroupAddress(6), config: &GroupAddressConfig{Name: "f", DPT: "14.001", Export: true}}, 86 | false, 87 | }, 88 | { 89 | "5.* can't unpack", 90 | knx.GroupEvent{Destination: cemi.GroupAddr(2), Command: knx.GroupWrite, Data: []byte{0}}, 91 | nil, 92 | true, 93 | }, 94 | { 95 | "bool unexported", 96 | knx.GroupEvent{Destination: cemi.GroupAddr(7), Command: knx.GroupWrite, Data: []byte{1}}, 97 | nil, 98 | true, 99 | }, 100 | { 101 | "unknown address", 102 | knx.GroupEvent{Destination: cemi.GroupAddr(255), Command: knx.GroupWrite, Data: []byte{0}}, 103 | nil, 104 | true, 105 | }, 106 | { 107 | "group read event", 108 | knx.GroupEvent{Destination: cemi.GroupAddr(6), Command: knx.GroupRead, Data: []byte{0}}, 109 | nil, 110 | true, 111 | }, 112 | } 113 | for _, tt := range tests { 114 | t.Run(tt.name, func(t *testing.T) { 115 | ctx, cancelFunc := context.WithTimeout(context.TODO(), 20*time.Millisecond) 116 | defer cancelFunc() 117 | 118 | inbound := make(chan knx.GroupEvent) 119 | metricsChan := make(chan *Snapshot) 120 | 121 | l := NewListener( 122 | &Config{ 123 | MetricsPrefix: "knx_", 124 | AddressConfigs: map[GroupAddress]*GroupAddressConfig{ 125 | GroupAddress(1): {Name: "a", DPT: "1.001", Export: true}, 126 | GroupAddress(2): {Name: "b", DPT: "5.001", Export: true}, 127 | GroupAddress(3): {Name: "c", DPT: "9.001", Export: true}, 128 | GroupAddress(4): {Name: "d", DPT: "12.001", Export: true}, 129 | GroupAddress(5): {Name: "e", DPT: "13.001", Export: true}, 130 | GroupAddress(6): {Name: "f", DPT: "14.001", Export: true}, 131 | GroupAddress(7): {Export: false}, 132 | }, 133 | }, 134 | metricsChan, 135 | prometheus.NewCounterVec(prometheus.CounterOpts{}, []string{"direction", "processed"}), 136 | ) 137 | 138 | go l.Run(ctx, inbound) 139 | inbound <- tt.event 140 | 141 | select { 142 | case got := <-metricsChan: 143 | // ignore timestamps 144 | got.timestamp = time.Unix(0, 0) 145 | tt.want.timestamp = time.Unix(0, 0) 146 | assert.Equal(t, tt.want, got) 147 | case <-ctx.Done(): 148 | assert.True(t, tt.wantError, "got no metrics snapshot but requires one") 149 | } 150 | close(inbound) 151 | close(metricsChan) 152 | }) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /pkg/knx/physical-address.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package knx 16 | 17 | import ( 18 | "encoding/json" 19 | "strings" 20 | 21 | "github.com/vapourismo/knx-go/knx/cemi" 22 | ) 23 | 24 | // PhysicalAddress defines an individual address of a knx device. 25 | type PhysicalAddress cemi.IndividualAddr 26 | 27 | const InvalidPhysicalAddress = PhysicalAddress(0) 28 | 29 | // NewPhysicalAddress creates a new knx device PhysicalAddress by parsing the given string. It either returns the parsed 30 | // PhysicalAddress or an error if it is not possible to parse the string. 31 | func NewPhysicalAddress(str string) (PhysicalAddress, error) { 32 | pa, e := cemi.NewIndividualAddrString(str) 33 | if e != nil { 34 | return InvalidPhysicalAddress, e 35 | } 36 | return PhysicalAddress(pa), nil 37 | } 38 | 39 | func (g PhysicalAddress) String() string { 40 | return cemi.IndividualAddr(g).String() 41 | } 42 | 43 | func (g PhysicalAddress) MarshalJSON() ([]byte, error) { 44 | return json.Marshal(g.String()) 45 | } 46 | 47 | func (g PhysicalAddress) MarshalText() ([]byte, error) { 48 | return []byte(g.String()), nil 49 | } 50 | 51 | func (g *PhysicalAddress) UnmarshalJSON(data []byte) error { 52 | str := string(data) 53 | str = strings.Trim(str, "\"'") 54 | ga, e := cemi.NewIndividualAddrString(str) 55 | if e != nil { 56 | return e 57 | } 58 | *g = PhysicalAddress(ga) 59 | return nil 60 | } 61 | func (g *PhysicalAddress) UnmarshalText(data []byte) error { 62 | return g.UnmarshalJSON(data) 63 | } 64 | -------------------------------------------------------------------------------- /pkg/knx/physical-address_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package knx 16 | 17 | import ( 18 | "testing" 19 | 20 | "github.com/stretchr/testify/assert" 21 | ) 22 | 23 | func TestPhysicalAddress_MarshalJSON(t *testing.T) { 24 | tests := []struct { 25 | name string 26 | g PhysicalAddress 27 | want []byte 28 | wantErr bool 29 | }{ 30 | {"0.0.0", PhysicalAddress(0), []byte("\"0.0.0\""), false}, 31 | {"0.0.1", PhysicalAddress(1), []byte("\"0.0.1\""), false}, 32 | {"0.1.0", PhysicalAddress(0x100), []byte("\"0.1.0\""), false}, 33 | {"15.15.0", PhysicalAddress(0xFF00), []byte("\"15.15.0\""), false}, 34 | } 35 | for _, tt := range tests { 36 | t.Run(tt.name, func(t *testing.T) { 37 | got, err := tt.g.MarshalJSON() 38 | if (err != nil) != tt.wantErr { 39 | t.Errorf("MarshalJSON() error = %v, wantErr %v", err, tt.wantErr) 40 | return 41 | } 42 | assert.Equal(t, tt.want, got) 43 | }) 44 | } 45 | } 46 | 47 | func TestPhysicalAddress_MarshalText(t *testing.T) { 48 | tests := []struct { 49 | name string 50 | g PhysicalAddress 51 | want []byte 52 | wantErr bool 53 | }{ 54 | {"0.0.0", PhysicalAddress(0), []byte("0.0.0"), false}, 55 | {"0.0.1", PhysicalAddress(1), []byte("0.0.1"), false}, 56 | {"0.1.0", PhysicalAddress(0x100), []byte("0.1.0"), false}, 57 | {"15.15.0", PhysicalAddress(0xFF00), []byte("15.15.0"), false}, 58 | } 59 | for _, tt := range tests { 60 | t.Run(tt.name, func(t *testing.T) { 61 | got, err := tt.g.MarshalText() 62 | if (err != nil) != tt.wantErr { 63 | t.Errorf("MarshalText() error = %v, wantErr %v", err, tt.wantErr) 64 | return 65 | } 66 | assert.Equal(t, tt.want, got) 67 | }) 68 | } 69 | } 70 | 71 | func TestPhysicalAddress_String(t *testing.T) { 72 | tests := []struct { 73 | name string 74 | g PhysicalAddress 75 | want string 76 | }{ 77 | {"0.0.0", PhysicalAddress(0), "0.0.0"}, 78 | {"0.0.1", PhysicalAddress(1), "0.0.1"}, 79 | {"0.1.0", PhysicalAddress(0x100), "0.1.0"}, 80 | {"15.15.0", PhysicalAddress(0xFF00), "15.15.0"}, 81 | } 82 | for _, tt := range tests { 83 | t.Run(tt.name, func(t *testing.T) { 84 | got := tt.g.String() 85 | assert.Equal(t, tt.want, got) 86 | }) 87 | } 88 | } 89 | 90 | func TestPhysicalAddress_UnmarshalJSON(t *testing.T) { 91 | tests := []struct { 92 | name string 93 | data []byte 94 | want PhysicalAddress 95 | wantErr bool 96 | }{ 97 | {"0.0.0", []byte("0.0.0"), PhysicalAddress(0), true}, 98 | {"0.0.1", []byte("0.0.1"), PhysicalAddress(1), false}, 99 | {"0.1.0", []byte("0.1.0"), PhysicalAddress(0x100), false}, 100 | {"15.15.0", []byte("15.15.0"), PhysicalAddress(0xFF00), false}, 101 | {"a.b.c", []byte("a.b.c"), PhysicalAddress(0), true}, 102 | } 103 | for _, tt := range tests { 104 | t.Run(tt.name, func(t *testing.T) { 105 | g := PhysicalAddress(0) 106 | if err := g.UnmarshalJSON(tt.data); (err != nil) != tt.wantErr { 107 | t.Errorf("UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) 108 | return 109 | } 110 | assert.Equal(t, tt.want, g) 111 | }) 112 | } 113 | } 114 | 115 | func TestPhysicalAddress_UnmarshalText(t *testing.T) { 116 | tests := []struct { 117 | name string 118 | data []byte 119 | want PhysicalAddress 120 | wantErr bool 121 | }{ 122 | {"0.0.0", []byte("0.0.0"), PhysicalAddress(0), true}, 123 | {"0.0.1", []byte("0.0.1"), PhysicalAddress(1), false}, 124 | {"0.1.0", []byte("0.1.0"), PhysicalAddress(0x100), false}, 125 | {"15.15.0", []byte("15.15.0"), PhysicalAddress(0xFF00), false}, 126 | {"a.b.c", []byte("a.b.c"), PhysicalAddress(0), true}, 127 | } 128 | for _, tt := range tests { 129 | t.Run(tt.name, func(t *testing.T) { 130 | g := PhysicalAddress(0) 131 | if err := g.UnmarshalText(tt.data); (err != nil) != tt.wantErr { 132 | t.Errorf("UnmarshalText() error = %v, wantErr %v", err, tt.wantErr) 133 | return 134 | } 135 | assert.Equal(t, tt.want, g) 136 | }) 137 | } 138 | } 139 | 140 | func TestNewPhysicalAddress(t *testing.T) { 141 | tests := []struct { 142 | name string 143 | want PhysicalAddress 144 | wantErr bool 145 | }{ 146 | {"0.0.0", PhysicalAddress(0), true}, 147 | {"0.0.1", PhysicalAddress(1), false}, 148 | {"0.0.1", PhysicalAddress(1), false}, 149 | {"0.1.0", PhysicalAddress(0x100), false}, 150 | {"15.15.0", PhysicalAddress(0xFF00), false}, 151 | {"31.7", PhysicalAddress(0x1f07), false}, 152 | {"31", PhysicalAddress(0x1f), false}, 153 | {"a.b.c", PhysicalAddress(0), true}, 154 | } 155 | for _, tt := range tests { 156 | t.Run(tt.name, func(t *testing.T) { 157 | got, err := NewPhysicalAddress(tt.name) 158 | if (err != nil) != tt.wantErr { 159 | t.Errorf("NewPhysicalAddress() error = %v, wantErr %v", err, tt.wantErr) 160 | return 161 | } 162 | assert.Equal(t, tt.want, got) 163 | }) 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /pkg/knx/poller.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package knx 16 | 17 | import ( 18 | "context" 19 | "log/slog" 20 | "math" 21 | "time" 22 | 23 | "github.com/prometheus/client_golang/prometheus" 24 | "github.com/vapourismo/knx-go/knx" 25 | "github.com/vapourismo/knx-go/knx/cemi" 26 | ) 27 | 28 | // Poller defines the interface for active polling for metrics values against the knx system. 29 | type Poller interface { 30 | // Run starts the polling. 31 | Run(ctx context.Context, client GroupClient, initialReading bool) 32 | } 33 | 34 | type poller struct { 35 | client GroupClient 36 | config *Config 37 | messageCounter *prometheus.CounterVec 38 | snapshotHandler MetricSnapshotHandler 39 | pollingInterval time.Duration 40 | metricsToPoll GroupAddressConfigSet 41 | cancelFunc context.CancelFunc 42 | } 43 | 44 | // NewPoller creates a new Poller instance using the given MetricsExporter for connection handling and metrics observing. 45 | func NewPoller(config *Config, metricsHandler MetricSnapshotHandler, messageCounter *prometheus.CounterVec) Poller { 46 | metricsToPoll := getMetricsToPoll(config) 47 | interval := calcPollingInterval(metricsToPoll) 48 | return &poller{ 49 | config: config, 50 | messageCounter: messageCounter, 51 | pollingInterval: interval, 52 | snapshotHandler: metricsHandler, 53 | metricsToPoll: metricsToPoll, 54 | } 55 | } 56 | 57 | func (p *poller) Run(ctx context.Context, client GroupClient, initialReading bool) { 58 | p.client = client 59 | if initialReading { 60 | go p.runInitialReading(ctx) 61 | } 62 | go p.runPolling(ctx) 63 | } 64 | 65 | func (p *poller) runInitialReading(ctx context.Context) { 66 | readInterval := time.Duration(p.config.ReadStartupInterval) 67 | if readInterval.Milliseconds() <= 0 { 68 | readInterval = 200 * time.Millisecond 69 | } 70 | slog.Info("start reading addresses after startup.", "delay", readInterval) 71 | 72 | metricsToRead := getMetricsToRead(p.config) 73 | ticker := time.NewTicker(readInterval) 74 | for address, config := range metricsToRead { 75 | select { 76 | case <-ticker.C: 77 | p.sendReadMessage(address, config) 78 | case <-ctx.Done(): 79 | break 80 | } 81 | } 82 | ticker.Stop() 83 | } 84 | 85 | func (p *poller) runPolling(ctx context.Context) { 86 | if p.pollingInterval <= 0 { 87 | return 88 | } 89 | slog.Log(nil, slog.LevelDebug-2, "Start polling group addresses", "pollingInterval", p.pollingInterval) 90 | ticker := time.NewTicker(p.pollingInterval) 91 | for { 92 | select { 93 | case t := <-ticker.C: 94 | p.pollAddresses(t) 95 | case <-ctx.Done(): 96 | ticker.Stop() 97 | return 98 | } 99 | } 100 | } 101 | 102 | func (p *poller) pollAddresses(t time.Time) { 103 | for address, config := range p.metricsToPoll { 104 | logger := slog.With("address", address) 105 | s := p.snapshotHandler.FindYoungestSnapshot(config.Name) 106 | if s == nil { 107 | logger.Log(nil, slog.LevelDebug-2, "Initial polling of address") 108 | p.sendReadMessage(address, config) 109 | continue 110 | } 111 | 112 | diff := t.Sub(s.timestamp).Round(time.Second) 113 | maxAge := time.Duration(config.MaxAge) 114 | if diff >= maxAge { 115 | logger.Log(nil, slog.LevelDebug-2, 116 | "Poll address for new value as it is to old", 117 | "maxAge", maxAge, 118 | "actualAge", diff, 119 | ) 120 | p.sendReadMessage(address, config) 121 | } 122 | } 123 | } 124 | 125 | func (p *poller) sendReadMessage(address GroupAddress, config *GroupAddressConfig) { 126 | event := knx.GroupEvent{ 127 | Command: knx.GroupRead, 128 | Source: cemi.IndividualAddr(p.config.Connection.PhysicalAddress), 129 | } 130 | 131 | if config.ReadType == WriteOther { 132 | event.Command = knx.GroupWrite 133 | event.Destination = cemi.GroupAddr(config.ReadAddress) 134 | event.Data = config.ReadBody 135 | } else { 136 | event.Destination = cemi.GroupAddr(address) 137 | } 138 | 139 | if e := p.client.Send(event); e != nil { 140 | slog.Info("Can not send read request: "+e.Error(), "address", address.String()) 141 | } 142 | p.messageCounter.WithLabelValues("sent", "true").Inc() 143 | } 144 | 145 | func getMetricsToRead(config *Config) GroupAddressConfigSet { 146 | toRead := make(GroupAddressConfigSet) 147 | for address, addressConfig := range config.AddressConfigs { 148 | if !addressConfig.Export || !addressConfig.ReadStartup { 149 | continue 150 | } 151 | 152 | toRead[address] = &GroupAddressConfig{ 153 | Name: config.NameFor(addressConfig), 154 | ReadStartup: true, 155 | ReadType: addressConfig.ReadType, 156 | ReadAddress: addressConfig.ReadAddress, 157 | ReadBody: addressConfig.ReadBody, 158 | } 159 | } 160 | return toRead 161 | } 162 | 163 | func getMetricsToPoll(config *Config) GroupAddressConfigSet { 164 | toPoll := make(GroupAddressConfigSet) 165 | for address, addressConfig := range config.AddressConfigs { 166 | interval := time.Duration(addressConfig.MaxAge).Truncate(time.Second) 167 | if !addressConfig.Export || !addressConfig.ReadActive || interval < time.Second { 168 | continue 169 | } 170 | 171 | interval = time.Duration(math.Max(float64(interval), float64(5*time.Second))) 172 | toPoll[address] = &GroupAddressConfig{ 173 | Name: config.NameFor(addressConfig), 174 | ReadActive: true, 175 | ReadType: addressConfig.ReadType, 176 | ReadAddress: addressConfig.ReadAddress, 177 | ReadBody: addressConfig.ReadBody, 178 | MaxAge: Duration(interval), 179 | } 180 | } 181 | return toPoll 182 | } 183 | 184 | func calcPollingInterval(config GroupAddressConfigSet) time.Duration { 185 | var intervals []time.Duration 186 | for _, ga := range config { 187 | intervals = append(intervals, time.Duration(ga.MaxAge)) 188 | } 189 | if len(intervals) == 0 { 190 | return -1 191 | } else if len(intervals) == 1 { 192 | return intervals[0] 193 | } 194 | 195 | ggt := int64(intervals[0].Seconds()) 196 | for i := 1; i < len(intervals); i++ { 197 | ggt = gcd(ggt, int64(intervals[i].Seconds())) 198 | } 199 | return time.Duration(ggt) * time.Second 200 | } 201 | 202 | // greatest common divisor (GCD) via Euclidean algorithm 203 | func gcd(a, b int64) int64 { 204 | for b != 0 { 205 | t := b 206 | b = a % b 207 | a = t 208 | } 209 | return a 210 | } 211 | -------------------------------------------------------------------------------- /pkg/knx/poller_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package knx 16 | 17 | import ( 18 | "context" 19 | "testing" 20 | "time" 21 | 22 | "github.com/golang/mock/gomock" 23 | "github.com/prometheus/client_golang/prometheus" 24 | "github.com/stretchr/testify/assert" 25 | "github.com/vapourismo/knx-go/knx" 26 | "github.com/vapourismo/knx-go/knx/cemi" 27 | ) 28 | 29 | func Test_getMetricsToPoll(t *testing.T) { 30 | 31 | tests := []struct { 32 | name string 33 | config *Config 34 | want GroupAddressConfigSet 35 | }{ 36 | {"empty", &Config{AddressConfigs: GroupAddressConfigSet{}}, GroupAddressConfigSet{}}, 37 | {"single-no-active-read", &Config{AddressConfigs: GroupAddressConfigSet{0: &GroupAddressConfig{ReadActive: false}}}, GroupAddressConfigSet{}}, 38 | {"single-too-small-interval", &Config{AddressConfigs: GroupAddressConfigSet{0: &GroupAddressConfig{ReadActive: true, MaxAge: Duration(10 * time.Millisecond)}}}, GroupAddressConfigSet{}}, 39 | {"single-no-export", &Config{AddressConfigs: GroupAddressConfigSet{0: &GroupAddressConfig{ReadActive: true, MaxAge: Duration(10 * time.Second), Export: false}}}, GroupAddressConfigSet{}}, 40 | {"single-small-interval", &Config{ 41 | AddressConfigs: GroupAddressConfigSet{0: &GroupAddressConfig{ReadActive: true, MaxAge: Duration(1 * time.Second), Name: "a", Export: true}}, 42 | MetricsPrefix: "knx_", 43 | }, GroupAddressConfigSet{0: &GroupAddressConfig{Name: "knx_a", ReadActive: true, MaxAge: Duration(5 * time.Second)}}}, 44 | {"single", &Config{ 45 | AddressConfigs: GroupAddressConfigSet{0: &GroupAddressConfig{ReadActive: true, MaxAge: Duration(10 * time.Second), Name: "a", Export: true}}, 46 | MetricsPrefix: "knx_", 47 | }, GroupAddressConfigSet{0: &GroupAddressConfig{Name: "knx_a", ReadActive: true, MaxAge: Duration(10 * time.Second)}}}, 48 | {"multiple", &Config{ 49 | AddressConfigs: GroupAddressConfigSet{ 50 | 0: &GroupAddressConfig{ReadActive: true, MaxAge: Duration(10 * time.Second), Name: "a", Export: true}, 51 | 1: &GroupAddressConfig{ReadActive: true, MaxAge: Duration(15 * time.Second), Name: "b", Export: true}, 52 | 2: &GroupAddressConfig{ReadActive: true, MaxAge: Duration(45 * time.Second), Name: "c", Export: true}, 53 | }, 54 | MetricsPrefix: "knx_", 55 | }, GroupAddressConfigSet{ 56 | 0: &GroupAddressConfig{Name: "knx_a", ReadActive: true, MaxAge: Duration(10 * time.Second)}, 57 | 1: &GroupAddressConfig{Name: "knx_b", ReadActive: true, MaxAge: Duration(15 * time.Second)}, 58 | 2: &GroupAddressConfig{Name: "knx_c", ReadActive: true, MaxAge: Duration(45 * time.Second)}, 59 | }}, 60 | } 61 | for _, tt := range tests { 62 | t.Run(tt.name, func(t *testing.T) { 63 | got := getMetricsToPoll(tt.config) 64 | assert.Equal(t, tt.want, got) 65 | }) 66 | } 67 | } 68 | 69 | func Test_getMetricsToRead(t *testing.T) { 70 | 71 | tests := []struct { 72 | name string 73 | config *Config 74 | want GroupAddressConfigSet 75 | }{ 76 | { 77 | "empty", 78 | &Config{AddressConfigs: GroupAddressConfigSet{}}, 79 | GroupAddressConfigSet{}, 80 | }, 81 | { 82 | "single-no-export-no-startup-read", 83 | &Config{AddressConfigs: GroupAddressConfigSet{0: &GroupAddressConfig{ReadStartup: false, Export: false}}}, 84 | GroupAddressConfigSet{}, 85 | }, 86 | { 87 | "single-no-export-startup-read", 88 | &Config{AddressConfigs: GroupAddressConfigSet{0: &GroupAddressConfig{Export: false, ReadStartup: true}}}, 89 | GroupAddressConfigSet{}, 90 | }, 91 | { 92 | "single-export-no-startup-read", 93 | &Config{AddressConfigs: GroupAddressConfigSet{0: &GroupAddressConfig{Export: true, ReadStartup: false}}}, 94 | GroupAddressConfigSet{}, 95 | }, 96 | { 97 | "single-export-startup-read", 98 | &Config{AddressConfigs: GroupAddressConfigSet{0: &GroupAddressConfig{Export: true, ReadStartup: true}}}, 99 | GroupAddressConfigSet{0: &GroupAddressConfig{ReadStartup: true}}, 100 | }, 101 | { 102 | "multiple-export-startup-read", 103 | &Config{AddressConfigs: GroupAddressConfigSet{ 104 | 0: &GroupAddressConfig{Export: false, ReadStartup: false}, 105 | 1: &GroupAddressConfig{Export: true, ReadStartup: false}, 106 | 2: &GroupAddressConfig{Export: false, ReadStartup: true}, 107 | 3: &GroupAddressConfig{Export: true, ReadStartup: true}, 108 | 4: &GroupAddressConfig{Export: true, ReadStartup: true}, 109 | }}, 110 | GroupAddressConfigSet{ 111 | 3: &GroupAddressConfig{ReadStartup: true}, 112 | 4: &GroupAddressConfig{ReadStartup: true}, 113 | }, 114 | }, 115 | } 116 | for _, tt := range tests { 117 | t.Run(tt.name, func(t *testing.T) { 118 | got := getMetricsToRead(tt.config) 119 | assert.Equal(t, tt.want, got) 120 | }) 121 | } 122 | } 123 | 124 | func Test_calcPollingInterval(t *testing.T) { 125 | tests := []struct { 126 | name string 127 | addresses GroupAddressConfigSet 128 | want time.Duration 129 | }{ 130 | {"empty", GroupAddressConfigSet{}, -1}, 131 | {"single", GroupAddressConfigSet{0: {ReadActive: true, MaxAge: Duration(10 * time.Second)}}, 10 * time.Second}, 132 | {"multiple", GroupAddressConfigSet{ 133 | 0: {ReadActive: true, MaxAge: Duration(10 * time.Second)}, 134 | 1: {ReadActive: true, MaxAge: Duration(15 * time.Second)}, 135 | 2: {ReadActive: true, MaxAge: Duration(30 * time.Second)}, 136 | 3: {ReadActive: true, MaxAge: Duration(45 * time.Second)}, 137 | 4: {ReadActive: true, MaxAge: Duration(60 * time.Second)}, 138 | 5: {ReadActive: true, MaxAge: Duration(90 * time.Second)}, 139 | }, 5 * time.Second}, 140 | } 141 | for _, tt := range tests { 142 | t.Run(tt.name, func(t *testing.T) { 143 | got := calcPollingInterval(tt.addresses) 144 | assert.Equal(t, tt.want, got) 145 | }) 146 | } 147 | } 148 | 149 | func TestPoller_Polling(t *testing.T) { 150 | ctrl := gomock.NewController(t) 151 | defer ctrl.Finish() 152 | ctx, cancelFunc := context.WithTimeout(context.TODO(), 6*time.Second) 153 | defer cancelFunc() 154 | 155 | groupClient := NewMockGroupClient(ctrl) 156 | mockSnapshotHandler := NewMockMetricSnapshotHandler(ctrl) 157 | messageCounter := prometheus.NewCounterVec(prometheus.CounterOpts{}, []string{"direction", "processed"}) 158 | 159 | config, err := ReadConfig("fixtures/readConfig.yaml") 160 | 161 | assert.NoError(t, err) 162 | 163 | mockSnapshotHandler.EXPECT(). 164 | FindYoungestSnapshot("knx_dummy_metric"). 165 | Return(&Snapshot{ 166 | name: "knx_dummy_metric", 167 | timestamp: time.Now().Add(-14 * time.Second), 168 | config: &GroupAddressConfig{}, 169 | }) 170 | mockSnapshotHandler.EXPECT(). 171 | FindYoungestSnapshot("knx_dummy_metric1"). 172 | Return(&Snapshot{ 173 | name: "knx_dummy_metric1", 174 | timestamp: time.Now().Add(2 * time.Second), 175 | config: &GroupAddressConfig{}, 176 | }) 177 | mockSnapshotHandler.EXPECT(). 178 | FindYoungestSnapshot("knx_dummy_metric2"). 179 | Return(nil) 180 | mockSnapshotHandler.EXPECT(). 181 | FindYoungestSnapshot("knx_dummy_metric4"). 182 | Return(&Snapshot{ 183 | name: "knx_dummy_metric4", 184 | timestamp: time.Now().Add(-16 * time.Second), 185 | config: &GroupAddressConfig{}, 186 | }) 187 | 188 | groupClient.EXPECT().Send(knx.GroupEvent{ 189 | Command: knx.GroupRead, Source: cemi.NewIndividualAddr3(2, 0, 1), Destination: cemi.NewGroupAddr3(0, 0, 1), 190 | }).Times(1) 191 | groupClient.EXPECT().Send(knx.GroupEvent{ 192 | Command: knx.GroupRead, Source: cemi.NewIndividualAddr3(2, 0, 1), Destination: cemi.NewGroupAddr3(0, 0, 2), 193 | }).Times(1) 194 | groupClient.EXPECT().Send(knx.GroupEvent{ 195 | Command: knx.GroupRead, Source: cemi.NewIndividualAddr3(2, 0, 1), Destination: cemi.NewGroupAddr3(0, 0, 3), 196 | }).Times(1) 197 | groupClient.EXPECT().Send(knx.GroupEvent{ 198 | Command: knx.GroupRead, Source: cemi.NewIndividualAddr3(2, 0, 1), Destination: cemi.NewGroupAddr3(0, 0, 4), 199 | }).Times(0) 200 | groupClient.EXPECT().Send(knx.GroupEvent{ 201 | Command: knx.GroupRead, Source: cemi.NewIndividualAddr3(2, 0, 1), Destination: cemi.NewGroupAddr3(0, 0, 5), 202 | }).Times(0) 203 | groupClient.EXPECT().Send(knx.GroupEvent{ 204 | Command: knx.GroupWrite, Source: cemi.NewIndividualAddr3(2, 0, 1), Destination: cemi.NewGroupAddr3(0, 0, 6), Data: []byte{1}, 205 | }).Times(1) 206 | 207 | groupClient.EXPECT().Send(knx.GroupEvent{ 208 | Command: knx.GroupRead, Source: cemi.NewIndividualAddr3(2, 0, 1), Destination: cemi.NewGroupAddr3(0, 0, 1), 209 | }).Times(1) 210 | groupClient.EXPECT().Send(knx.GroupEvent{ 211 | Command: knx.GroupRead, Source: cemi.NewIndividualAddr3(2, 0, 1), Destination: cemi.NewGroupAddr3(0, 0, 3), 212 | }).Times(1) 213 | groupClient.EXPECT().Send(knx.GroupEvent{ 214 | Command: knx.GroupRead, Source: cemi.NewIndividualAddr3(2, 0, 1), Destination: cemi.NewGroupAddr3(0, 0, 5), 215 | }).Times(0) 216 | groupClient.EXPECT().Send(knx.GroupEvent{ 217 | Command: knx.GroupWrite, Source: cemi.NewIndividualAddr3(2, 0, 1), Destination: cemi.NewGroupAddr3(0, 0, 6), Data: []byte{1}, 218 | }).Times(1) 219 | 220 | p := NewPoller(config, mockSnapshotHandler, messageCounter) 221 | p.Run(ctx, groupClient, true) 222 | time.Sleep(5500 * time.Millisecond) 223 | } 224 | -------------------------------------------------------------------------------- /pkg/knx/snapshot.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package knx 16 | 17 | //go:generate mockgen -destination=snapshotMocks_test.go -package=knx -self_package=github.com/chr-fritz/knx-exporter/pkg/knx -source=snapshot.go 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "strings" 23 | "sync" 24 | "time" 25 | 26 | "github.com/prometheus/client_golang/prometheus" 27 | ) 28 | 29 | // MetricSnapshotHandler holds and manages all the snapshots of metrics. 30 | type MetricSnapshotHandler interface { 31 | prometheus.Collector 32 | 33 | // AddSnapshot adds a new snapshot that should be exported as metric. 34 | AddSnapshot(snapshot *Snapshot) 35 | // FindSnapshot finds a given snapshot by the snapshots key. 36 | FindSnapshot(key SnapshotKey) (*Snapshot, error) 37 | // FindYoungestSnapshot finds the youngest snapshot with the given metric name. 38 | // It don't matter from which device the snapshot was received. 39 | FindYoungestSnapshot(name string) *Snapshot 40 | // Run let the MetricSnapshotHandler listen for new snapshots on the Snapshot channel. 41 | Run(ctx context.Context) 42 | // GetMetricsChannel returns the channel to send new snapshots to this MetricSnapshotHandler. 43 | GetMetricsChannel() chan *Snapshot 44 | // IsActive indicates that this handler is active and waits for new metric snapshots 45 | IsActive() bool 46 | } 47 | 48 | // SnapshotKey identifies all the snapshots that were received from a specific device and exported with the specific name. 49 | type SnapshotKey struct { 50 | source PhysicalAddress 51 | target GroupAddress 52 | } 53 | 54 | // Snapshot stores all information about a single metric snapshot. 55 | type Snapshot struct { 56 | name string 57 | source PhysicalAddress 58 | destination GroupAddress 59 | value float64 60 | timestamp time.Time 61 | config *GroupAddressConfig 62 | } 63 | 64 | type metricSnapshots struct { 65 | lock sync.RWMutex 66 | snapshots map[SnapshotKey]*Snapshot 67 | descriptions map[SnapshotKey]*prometheus.Desc 68 | metricsChan chan *Snapshot 69 | active bool 70 | } 71 | 72 | func NewMetricsSnapshotHandler() MetricSnapshotHandler { 73 | return &metricSnapshots{ 74 | lock: sync.RWMutex{}, 75 | snapshots: make(map[SnapshotKey]*Snapshot), 76 | descriptions: make(map[SnapshotKey]*prometheus.Desc), 77 | metricsChan: make(chan *Snapshot), 78 | active: true, 79 | } 80 | } 81 | 82 | func (m *metricSnapshots) AddSnapshot(s *Snapshot) { 83 | key := s.getKey() 84 | m.lock.Lock() 85 | defer m.lock.Unlock() 86 | _, ok := m.descriptions[key] 87 | 88 | if !ok { 89 | m.descriptions[key] = createMetric(s) 90 | } 91 | m.snapshots[key] = s 92 | } 93 | 94 | func (m *metricSnapshots) FindSnapshot(key SnapshotKey) (*Snapshot, error) { 95 | m.lock.RLock() 96 | defer m.lock.RUnlock() 97 | snapshot, ok := m.snapshots[key] 98 | if !ok { 99 | return nil, fmt.Errorf("no snapshot for %s from %s found", key.target.String(), key.source.String()) 100 | } 101 | return snapshot, nil 102 | } 103 | 104 | func (m *metricSnapshots) FindYoungestSnapshot(name string) *Snapshot { 105 | m.lock.RLock() 106 | defer m.lock.RUnlock() 107 | 108 | var youngest *Snapshot 109 | for _, s := range m.snapshots { 110 | if s.name != name { 111 | continue 112 | } 113 | if youngest == nil { 114 | youngest = s 115 | continue 116 | } 117 | if youngest.timestamp.Before(s.timestamp) { 118 | youngest = s 119 | } 120 | } 121 | return youngest 122 | } 123 | 124 | func (m *metricSnapshots) Run(ctx context.Context) { 125 | m.active = true 126 | defer func() { m.active = false }() 127 | for { 128 | select { 129 | case snap := <-m.metricsChan: 130 | m.AddSnapshot(snap) 131 | case <-ctx.Done(): 132 | break 133 | } 134 | } 135 | } 136 | 137 | func (m *metricSnapshots) IsActive() bool { 138 | return m.active 139 | } 140 | 141 | func (m *metricSnapshots) GetMetricsChannel() chan *Snapshot { 142 | return m.metricsChan 143 | } 144 | 145 | func (m *metricSnapshots) Describe(ch chan<- *prometheus.Desc) { 146 | for _, d := range m.descriptions { 147 | ch <- d 148 | } 149 | } 150 | 151 | func (m *metricSnapshots) Collect(metrics chan<- prometheus.Metric) { 152 | for k, s := range m.snapshots { 153 | if s.config.WithTimestamp { 154 | metrics <- prometheus.NewMetricWithTimestamp(s.timestamp, prometheus.MustNewConstMetric(m.descriptions[k], s.getValuetype(), s.value)) 155 | } else { 156 | metrics <- prometheus.MustNewConstMetric(m.descriptions[k], s.getValuetype(), s.value) 157 | } 158 | } 159 | } 160 | 161 | func (s *Snapshot) getKey() SnapshotKey { 162 | return SnapshotKey{ 163 | source: s.source, 164 | target: s.destination, 165 | } 166 | } 167 | 168 | func (s *Snapshot) getValuetype() prometheus.ValueType { 169 | if strings.ToLower(s.config.MetricType) == "counter" { 170 | return prometheus.CounterValue 171 | } else if strings.ToLower(s.config.MetricType) == "gauge" { 172 | return prometheus.GaugeValue 173 | } 174 | return prometheus.UntypedValue 175 | } 176 | 177 | func createMetric(s *Snapshot) *prometheus.Desc { 178 | return prometheus.NewDesc(s.name, s.config.Comment, []string{}, getSnapshotLabels(s)) 179 | } 180 | 181 | // getSnapshotLabels returns a full list of all labels that should be added to the given metric. 182 | func getSnapshotLabels(s *Snapshot) map[string]string { 183 | var labels = map[string]string{ 184 | "physicalAddress": s.source.String(), 185 | } 186 | if s.config.Labels != nil { 187 | for name, value := range s.config.Labels { 188 | labels[name] = value 189 | } 190 | } 191 | return labels 192 | } 193 | -------------------------------------------------------------------------------- /pkg/knx/snapshotMocks_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Code generated by MockGen. DO NOT EDIT. 16 | // Source: snapshot.go 17 | 18 | // Package knx is a generated GoMock package. 19 | package knx 20 | 21 | import ( 22 | context "context" 23 | reflect "reflect" 24 | 25 | gomock "github.com/golang/mock/gomock" 26 | prometheus "github.com/prometheus/client_golang/prometheus" 27 | ) 28 | 29 | // MockMetricSnapshotHandler is a mock of MetricSnapshotHandler interface. 30 | type MockMetricSnapshotHandler struct { 31 | ctrl *gomock.Controller 32 | recorder *MockMetricSnapshotHandlerMockRecorder 33 | } 34 | 35 | // MockMetricSnapshotHandlerMockRecorder is the mock recorder for MockMetricSnapshotHandler. 36 | type MockMetricSnapshotHandlerMockRecorder struct { 37 | mock *MockMetricSnapshotHandler 38 | } 39 | 40 | // NewMockMetricSnapshotHandler creates a new mock instance. 41 | func NewMockMetricSnapshotHandler(ctrl *gomock.Controller) *MockMetricSnapshotHandler { 42 | mock := &MockMetricSnapshotHandler{ctrl: ctrl} 43 | mock.recorder = &MockMetricSnapshotHandlerMockRecorder{mock} 44 | return mock 45 | } 46 | 47 | // EXPECT returns an object that allows the caller to indicate expected use. 48 | func (m *MockMetricSnapshotHandler) EXPECT() *MockMetricSnapshotHandlerMockRecorder { 49 | return m.recorder 50 | } 51 | 52 | // AddSnapshot mocks base method. 53 | func (m *MockMetricSnapshotHandler) AddSnapshot(snapshot *Snapshot) { 54 | m.ctrl.T.Helper() 55 | m.ctrl.Call(m, "AddSnapshot", snapshot) 56 | } 57 | 58 | // AddSnapshot indicates an expected call of AddSnapshot. 59 | func (mr *MockMetricSnapshotHandlerMockRecorder) AddSnapshot(snapshot interface{}) *gomock.Call { 60 | mr.mock.ctrl.T.Helper() 61 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddSnapshot", reflect.TypeOf((*MockMetricSnapshotHandler)(nil).AddSnapshot), snapshot) 62 | } 63 | 64 | // Collect mocks base method. 65 | func (m *MockMetricSnapshotHandler) Collect(arg0 chan<- prometheus.Metric) { 66 | m.ctrl.T.Helper() 67 | m.ctrl.Call(m, "Collect", arg0) 68 | } 69 | 70 | // Collect indicates an expected call of Collect. 71 | func (mr *MockMetricSnapshotHandlerMockRecorder) Collect(arg0 interface{}) *gomock.Call { 72 | mr.mock.ctrl.T.Helper() 73 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Collect", reflect.TypeOf((*MockMetricSnapshotHandler)(nil).Collect), arg0) 74 | } 75 | 76 | // Describe mocks base method. 77 | func (m *MockMetricSnapshotHandler) Describe(arg0 chan<- *prometheus.Desc) { 78 | m.ctrl.T.Helper() 79 | m.ctrl.Call(m, "Describe", arg0) 80 | } 81 | 82 | // Describe indicates an expected call of Describe. 83 | func (mr *MockMetricSnapshotHandlerMockRecorder) Describe(arg0 interface{}) *gomock.Call { 84 | mr.mock.ctrl.T.Helper() 85 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Describe", reflect.TypeOf((*MockMetricSnapshotHandler)(nil).Describe), arg0) 86 | } 87 | 88 | // FindSnapshot mocks base method. 89 | func (m *MockMetricSnapshotHandler) FindSnapshot(key SnapshotKey) (*Snapshot, error) { 90 | m.ctrl.T.Helper() 91 | ret := m.ctrl.Call(m, "FindSnapshot", key) 92 | ret0, _ := ret[0].(*Snapshot) 93 | ret1, _ := ret[1].(error) 94 | return ret0, ret1 95 | } 96 | 97 | // FindSnapshot indicates an expected call of FindSnapshot. 98 | func (mr *MockMetricSnapshotHandlerMockRecorder) FindSnapshot(key interface{}) *gomock.Call { 99 | mr.mock.ctrl.T.Helper() 100 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindSnapshot", reflect.TypeOf((*MockMetricSnapshotHandler)(nil).FindSnapshot), key) 101 | } 102 | 103 | // FindYoungestSnapshot mocks base method. 104 | func (m *MockMetricSnapshotHandler) FindYoungestSnapshot(name string) *Snapshot { 105 | m.ctrl.T.Helper() 106 | ret := m.ctrl.Call(m, "FindYoungestSnapshot", name) 107 | ret0, _ := ret[0].(*Snapshot) 108 | return ret0 109 | } 110 | 111 | // FindYoungestSnapshot indicates an expected call of FindYoungestSnapshot. 112 | func (mr *MockMetricSnapshotHandlerMockRecorder) FindYoungestSnapshot(name interface{}) *gomock.Call { 113 | mr.mock.ctrl.T.Helper() 114 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindYoungestSnapshot", reflect.TypeOf((*MockMetricSnapshotHandler)(nil).FindYoungestSnapshot), name) 115 | } 116 | 117 | // GetMetricsChannel mocks base method. 118 | func (m *MockMetricSnapshotHandler) GetMetricsChannel() chan *Snapshot { 119 | m.ctrl.T.Helper() 120 | ret := m.ctrl.Call(m, "GetMetricsChannel") 121 | ret0, _ := ret[0].(chan *Snapshot) 122 | return ret0 123 | } 124 | 125 | // GetMetricsChannel indicates an expected call of GetMetricsChannel. 126 | func (mr *MockMetricSnapshotHandlerMockRecorder) GetMetricsChannel() *gomock.Call { 127 | mr.mock.ctrl.T.Helper() 128 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMetricsChannel", reflect.TypeOf((*MockMetricSnapshotHandler)(nil).GetMetricsChannel)) 129 | } 130 | 131 | // IsActive mocks base method. 132 | func (m *MockMetricSnapshotHandler) IsActive() bool { 133 | m.ctrl.T.Helper() 134 | ret := m.ctrl.Call(m, "IsActive") 135 | ret0, _ := ret[0].(bool) 136 | return ret0 137 | } 138 | 139 | // IsActive indicates an expected call of IsActive. 140 | func (mr *MockMetricSnapshotHandlerMockRecorder) IsActive() *gomock.Call { 141 | mr.mock.ctrl.T.Helper() 142 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsActive", reflect.TypeOf((*MockMetricSnapshotHandler)(nil).IsActive)) 143 | } 144 | 145 | // Run mocks base method. 146 | func (m *MockMetricSnapshotHandler) Run(ctx context.Context) { 147 | m.ctrl.T.Helper() 148 | m.ctrl.Call(m, "Run", ctx) 149 | } 150 | 151 | // Run indicates an expected call of Run. 152 | func (mr *MockMetricSnapshotHandlerMockRecorder) Run(ctx interface{}) *gomock.Call { 153 | mr.mock.ctrl.T.Helper() 154 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Run", reflect.TypeOf((*MockMetricSnapshotHandler)(nil).Run), ctx) 155 | } 156 | -------------------------------------------------------------------------------- /pkg/knx/snapshot_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package knx 16 | 17 | import ( 18 | "sync" 19 | "testing" 20 | "time" 21 | 22 | "github.com/golang/mock/gomock" 23 | "github.com/prometheus/client_golang/prometheus" 24 | "github.com/stretchr/testify/assert" 25 | ) 26 | 27 | func TestSnapshot_getKey(t *testing.T) { 28 | tests := []struct { 29 | name string 30 | snapshot *Snapshot 31 | want SnapshotKey 32 | }{ 33 | {"ok", &Snapshot{name: "metricName", source: 1, config: &GroupAddressConfig{Labels: nil}, destination: 1}, SnapshotKey{target: 1, source: 1}}, 34 | } 35 | for _, tt := range tests { 36 | t.Run(tt.name, func(t *testing.T) { 37 | got := tt.snapshot.getKey() 38 | assert.Equal(t, tt.want, got) 39 | }) 40 | } 41 | } 42 | 43 | func Test_metricSnapshots_AddSnapshot(t *testing.T) { 44 | 45 | tests := []struct { 46 | name string 47 | s *Snapshot 48 | key SnapshotKey 49 | wantRegister int 50 | }{ 51 | {"new counter", &Snapshot{name: "a", source: 1, destination: 1, config: &GroupAddressConfig{MetricType: "counter"}}, SnapshotKey{target: 1, source: 1}, 1}, 52 | {"new gauge", &Snapshot{name: "b", source: 1, destination: 2, config: &GroupAddressConfig{MetricType: "gauge", Labels: map[string]string{"room": "office"}}}, SnapshotKey{target: 2, source: 1}, 1}, 53 | {"update", &Snapshot{name: "c", source: 1, destination: 3, config: &GroupAddressConfig{MetricType: "gauge"}}, SnapshotKey{target: 3, source: 1}, 0}, 54 | } 55 | for _, tt := range tests { 56 | t.Run(tt.name, func(t *testing.T) { 57 | ctrl := gomock.NewController(t) 58 | defer ctrl.Finish() 59 | 60 | handler := NewMetricsSnapshotHandler() 61 | snapshots := handler.(*metricSnapshots) 62 | snapshots.descriptions[SnapshotKey{source: 1, target: 3}] = prometheus.NewDesc("", "", []string{}, map[string]string{}) 63 | handler.AddSnapshot(tt.s) 64 | assert.NotNil(t, snapshots.descriptions[tt.key]) 65 | assert.NotNil(t, snapshots.snapshots[tt.key]) 66 | }) 67 | } 68 | } 69 | 70 | func Test_metricSnapshots_FindSnapshot(t *testing.T) { 71 | tests := []struct { 72 | name string 73 | existingSnapshots map[SnapshotKey]*Snapshot 74 | key SnapshotKey 75 | want *Snapshot 76 | wantErr bool 77 | }{ 78 | { 79 | "found", 80 | map[SnapshotKey]*Snapshot{SnapshotKey{source: 1, target: 2}: {name: "found"}}, 81 | SnapshotKey{source: 1, target: 2}, 82 | &Snapshot{name: "found"}, 83 | false}, 84 | { 85 | "found two devs", 86 | map[SnapshotKey]*Snapshot{ 87 | SnapshotKey{source: 1, target: 1}: {name: "found", source: 1}, 88 | SnapshotKey{source: 2, target: 1}: {name: "found", source: 2}, 89 | }, 90 | SnapshotKey{source: 1, target: 1}, 91 | &Snapshot{name: "found", source: 1}, 92 | false}, 93 | { 94 | "found one dev", 95 | map[SnapshotKey]*Snapshot{ 96 | SnapshotKey{source: 1, target: 1}: {name: "found1", source: 1}, 97 | SnapshotKey{source: 1, target: 2}: {name: "found2", source: 1}, 98 | }, 99 | SnapshotKey{source: 1, target: 1}, 100 | &Snapshot{name: "found1", source: 1}, 101 | false}, 102 | { 103 | "no snapshots", 104 | map[SnapshotKey]*Snapshot{}, 105 | SnapshotKey{source: 2, target: 1}, 106 | nil, 107 | true}, 108 | } 109 | for _, tt := range tests { 110 | t.Run(tt.name, func(t *testing.T) { 111 | m := &metricSnapshots{ 112 | lock: sync.RWMutex{}, 113 | snapshots: tt.existingSnapshots, 114 | } 115 | got, err := m.FindSnapshot(tt.key) 116 | if (err != nil) != tt.wantErr { 117 | t.Errorf("FindSnapshot() error = %v, wantErr %v", err, tt.wantErr) 118 | return 119 | } 120 | assert.Equal(t, tt.want, got) 121 | }) 122 | } 123 | } 124 | 125 | func Test_metricSnapshots_FindYoungestSnapshot(t *testing.T) { 126 | testTime := time.Now() 127 | tests := []struct { 128 | name string 129 | existingSnapshots map[SnapshotKey]*Snapshot 130 | metricName string 131 | want *Snapshot 132 | }{ 133 | { 134 | "no snapshots", 135 | map[SnapshotKey]*Snapshot{}, 136 | "metric", 137 | nil, 138 | }, 139 | { 140 | "one snapshot", 141 | map[SnapshotKey]*Snapshot{SnapshotKey{source: 1, target: 1}: {name: "a", source: 1}}, 142 | "a", 143 | &Snapshot{name: "a", source: 1}, 144 | }, 145 | { 146 | "two dev", 147 | map[SnapshotKey]*Snapshot{ 148 | SnapshotKey{source: 1, target: 1}: {name: "a", source: 1, timestamp: testTime}, 149 | SnapshotKey{source: 2, target: 1}: {name: "a", source: 2, timestamp: testTime.Add(-10 * time.Second)}, 150 | SnapshotKey{source: 3, target: 1}: {name: "a", source: 3, timestamp: testTime.Add(-20 * time.Second)}, 151 | }, 152 | "a", 153 | &Snapshot{name: "a", source: 1, timestamp: testTime}, 154 | }, 155 | { 156 | "two metrics", 157 | map[SnapshotKey]*Snapshot{ 158 | SnapshotKey{source: 1, target: 1}: {name: "a", source: 1}, 159 | SnapshotKey{source: 1, target: 2}: {name: "b", source: 1}, 160 | }, 161 | "a", 162 | &Snapshot{name: "a", source: 1}, 163 | }, 164 | } 165 | for _, tt := range tests { 166 | t.Run(tt.name, func(t *testing.T) { 167 | m := &metricSnapshots{ 168 | lock: sync.RWMutex{}, 169 | snapshots: tt.existingSnapshots, 170 | } 171 | got := m.FindYoungestSnapshot(tt.metricName) 172 | assert.Equal(t, tt.want, got) 173 | }) 174 | } 175 | } 176 | 177 | func Test_metricSnapshots_Describe(t *testing.T) { 178 | tests := []struct { 179 | name string 180 | snapshots []*Snapshot 181 | expectedDesc []*prometheus.Desc 182 | }{ 183 | {"no snapshots", []*Snapshot{}, []*prometheus.Desc{}}, 184 | {"single snapshots", 185 | []*Snapshot{{name: "dummy", value: 1, source: 1, config: &GroupAddressConfig{Comment: "abc"}}}, 186 | []*prometheus.Desc{ 187 | prometheus.NewDesc("dummy", "abc", []string{}, map[string]string{"physicalAddress": "0.0.1"}), 188 | }, 189 | }, 190 | { 191 | "two different snapshots", 192 | []*Snapshot{ 193 | {name: "dummy", value: 1, source: 1, config: &GroupAddressConfig{}}, 194 | {name: "dummy1", value: 2, source: 2, config: &GroupAddressConfig{Labels: map[string]string{"room": "outside"}}}, 195 | }, 196 | []*prometheus.Desc{ 197 | prometheus.NewDesc("dummy", "", []string{}, map[string]string{"physicalAddress": "0.0.1"}), 198 | prometheus.NewDesc("dummy1", "", []string{}, map[string]string{"physicalAddress": "0.0.2", "room": "outside"}), 199 | }, 200 | }, 201 | { 202 | "duplicate snapshots", 203 | []*Snapshot{ 204 | {name: "dummy", value: 1, source: 1, config: &GroupAddressConfig{}}, 205 | {name: "dummy", value: 2, source: 1, config: &GroupAddressConfig{}}, 206 | }, 207 | []*prometheus.Desc{ 208 | prometheus.NewDesc("dummy", "", []string{}, map[string]string{"physicalAddress": "0.0.1"}), 209 | }, 210 | }, 211 | } 212 | for _, tt := range tests { 213 | t.Run(tt.name, func(t *testing.T) { 214 | handler := NewMetricsSnapshotHandler() 215 | for _, snapshot := range tt.snapshots { 216 | handler.AddSnapshot(snapshot) 217 | } 218 | 219 | ch := make(chan *prometheus.Desc) 220 | actualDesc := make([]*prometheus.Desc, 0) 221 | go func() { 222 | for desc := range ch { 223 | actualDesc = append(actualDesc, desc) 224 | } 225 | }() 226 | 227 | handler.Describe(ch) 228 | time.Sleep(100 * time.Millisecond) 229 | close(ch) 230 | assert.Equal(t, tt.expectedDesc, actualDesc) 231 | }) 232 | } 233 | } 234 | 235 | func Test_metricSnapshots_Collect(t *testing.T) { 236 | testTime := time.Now() 237 | tests := []struct { 238 | name string 239 | snapshots []*Snapshot 240 | metrics []prometheus.Metric 241 | }{ 242 | {"no metrics", []*Snapshot{}, []prometheus.Metric{}}, 243 | {"single counter metric", 244 | []*Snapshot{ 245 | {name: "dummy", value: 1, source: 1, timestamp: testTime, config: &GroupAddressConfig{MetricType: "counter"}}, 246 | }, 247 | []prometheus.Metric{ 248 | prometheus.MustNewConstMetric(prometheus.NewDesc("dummy", "", []string{}, map[string]string{"physicalAddress": "0.0.1"}), prometheus.CounterValue, 1), 249 | }, 250 | }, 251 | {"single gauge timestamp metric", 252 | []*Snapshot{ 253 | {name: "dummy", value: 1, source: 1, timestamp: testTime, config: &GroupAddressConfig{MetricType: "gauge", WithTimestamp: true}}, 254 | }, 255 | []prometheus.Metric{ 256 | prometheus.NewMetricWithTimestamp(testTime, prometheus.MustNewConstMetric(prometheus.NewDesc("dummy", "", []string{}, map[string]string{"physicalAddress": "0.0.1"}), prometheus.GaugeValue, 1)), 257 | }, 258 | }, 259 | } 260 | for _, tt := range tests { 261 | t.Run(tt.name, func(t *testing.T) { 262 | handler := NewMetricsSnapshotHandler() 263 | for _, snapshot := range tt.snapshots { 264 | handler.AddSnapshot(snapshot) 265 | } 266 | 267 | ch := make(chan prometheus.Metric) 268 | actualMetrics := make([]prometheus.Metric, 0) 269 | go func() { 270 | for desc := range ch { 271 | actualMetrics = append(actualMetrics, desc) 272 | } 273 | }() 274 | 275 | handler.Collect(ch) 276 | time.Sleep(100 * time.Millisecond) 277 | close(ch) 278 | assert.Equal(t, tt.metrics, actualMetrics) 279 | }) 280 | } 281 | } 282 | -------------------------------------------------------------------------------- /pkg/logging/logging.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package logging 16 | 17 | import ( 18 | "github.com/spf13/viper" 19 | "log/slog" 20 | "os" 21 | "strings" 22 | 23 | "github.com/spf13/cobra" 24 | "github.com/spf13/pflag" 25 | ) 26 | 27 | type LoggerConfiguration interface { 28 | Initialize() 29 | } 30 | 31 | type loggerConfig struct { 32 | flagSet *pflag.FlagSet 33 | level string 34 | formatterName string 35 | } 36 | 37 | func InitFlags(flagset *pflag.FlagSet, cmd *cobra.Command) LoggerConfiguration { 38 | if flagset == nil { 39 | flagset = pflag.CommandLine 40 | } 41 | config := &loggerConfig{ 42 | flagSet: flagset, 43 | } 44 | 45 | logLevelFlagName := "log_level" 46 | logFormatterFlagName := "log_format" 47 | flagset.StringVarP(&config.level, logLevelFlagName, "v", "info", "The minimum log level to print the messages.") 48 | flagset.StringVarP(&config.formatterName, logFormatterFlagName, "", "text", "The format how to print the log messages.") 49 | 50 | _ = viper.BindPFlag("logging.level", flagset.Lookup(logLevelFlagName)) 51 | _ = viper.BindPFlag("logging.format", flagset.Lookup(logFormatterFlagName)) 52 | 53 | if cmd != nil { 54 | if e := cmd.RegisterFlagCompletionFunc(logLevelFlagName, flagCompletion); e != nil { 55 | slog.Warn("can not register flag completion for log_level", "err", e) 56 | } 57 | 58 | e := cmd.RegisterFlagCompletionFunc(logFormatterFlagName, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { 59 | return []string{"text", "json"}, cobra.ShellCompDirectiveDefault 60 | }) 61 | if e != nil { 62 | slog.Warn("can not register flag completion for log formatter", "err", e) 63 | } 64 | } 65 | 66 | return config 67 | } 68 | 69 | func flagCompletion(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { 70 | return []string{"error", "warn", "info", "debug"}, cobra.ShellCompDirectiveDefault 71 | } 72 | 73 | func (lc *loggerConfig) Initialize() { 74 | level := lc.setLevel() 75 | 76 | opts := &slog.HandlerOptions{ 77 | AddSource: true, 78 | Level: level, 79 | ReplaceAttr: nil, 80 | } 81 | logger := slog.New(lc.setFormatter(opts)) 82 | slog.SetDefault(logger) 83 | } 84 | 85 | func (lc *loggerConfig) setLevel() slog.Level { 86 | var level slog.Level 87 | e := level.UnmarshalText([]byte(lc.level)) 88 | 89 | if e != nil { 90 | slog.Warn("Can not parse level", "invalid-level", lc.level) 91 | return slog.LevelInfo 92 | } 93 | return level 94 | } 95 | func (lc *loggerConfig) setFormatter(options *slog.HandlerOptions) slog.Handler { 96 | switch strings.ToLower(lc.formatterName) { 97 | case "json": 98 | return slog.NewJSONHandler(os.Stdout, options) 99 | case "text": 100 | fallthrough 101 | default: 102 | return slog.NewTextHandler(os.Stdout, options) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /pkg/logging/logging_test.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package logging 16 | 17 | import ( 18 | "log/slog" 19 | "testing" 20 | 21 | "github.com/stretchr/testify/assert" 22 | ) 23 | 24 | func Test_loggerConfig_Initialize(t *testing.T) { 25 | tests := []struct { 26 | name string 27 | level string 28 | format string 29 | expectedLevel slog.Level 30 | expectedFormatter slog.Handler 31 | }{ 32 | { 33 | "info text to stderr", 34 | "info", 35 | "text", 36 | slog.LevelInfo, 37 | &slog.TextHandler{}, 38 | }, 39 | { 40 | "info text as json", 41 | "info", 42 | "json", 43 | slog.LevelInfo, 44 | &slog.JSONHandler{}, 45 | }, 46 | { 47 | "unknown log formatter", 48 | "info", 49 | "unknown", 50 | slog.LevelInfo, 51 | &slog.TextHandler{}, 52 | }, 53 | { 54 | "invalid debug level", 55 | "not valid", 56 | "text", 57 | slog.LevelInfo, 58 | &slog.TextHandler{}, 59 | }, 60 | } 61 | for _, tt := range tests { 62 | t.Run(tt.name, func(t *testing.T) { 63 | lc := &loggerConfig{ 64 | level: tt.level, 65 | formatterName: tt.format, 66 | } 67 | lc.Initialize() 68 | logger := slog.With("dummy") 69 | assert.True(t, logger.Enabled(nil, tt.expectedLevel)) 70 | assert.False(t, logger.Enabled(nil, tt.expectedLevel-1)) 71 | assert.IsType(t, tt.expectedFormatter, logger.Handler()) 72 | }) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /pkg/metrics/exporter.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package metrics 16 | 17 | //go:generate mockgen -destination=fake/exporterMocks.go -package=fake -source=exporter.go 18 | import ( 19 | "context" 20 | "fmt" 21 | "net/http" 22 | 23 | "github.com/coreos/go-systemd/v22/daemon" 24 | "github.com/heptiolabs/healthcheck" 25 | "github.com/prometheus/client_golang/prometheus" 26 | "github.com/prometheus/client_golang/prometheus/promhttp" 27 | ) 28 | 29 | type exporter struct { 30 | Port uint16 31 | health healthcheck.Handler 32 | meterRegistry *prometheus.Registry 33 | server *http.Server 34 | } 35 | 36 | type Exporter interface { 37 | Run(ctx context.Context) error 38 | MustRegister(collectors ...prometheus.Collector) 39 | Register(collector prometheus.Collector) error 40 | Unregister(collector prometheus.Collector) bool 41 | AddLivenessCheck(name string, check healthcheck.Check) 42 | AddReadinessCheck(name string, check healthcheck.Check) 43 | } 44 | 45 | func NewExporter(port uint16, withGoMetrics bool) Exporter { 46 | registry := prometheus.DefaultRegisterer.(*prometheus.Registry) 47 | if !withGoMetrics { 48 | registry = prometheus.NewPedanticRegistry() 49 | } 50 | return &exporter{ 51 | Port: port, 52 | health: healthcheck.NewHandler(), 53 | meterRegistry: registry, 54 | server: &http.Server{Addr: fmt.Sprintf("0.0.0.0:%d", port)}, 55 | } 56 | } 57 | 58 | func (e exporter) Run(ctx context.Context) error { 59 | server := http.NewServeMux() 60 | 61 | server.HandleFunc("/live", e.health.LiveEndpoint) 62 | server.HandleFunc("/ready", e.health.ReadyEndpoint) 63 | handler := promhttp.HandlerFor(e.meterRegistry, promhttp.HandlerOpts{EnableOpenMetrics: true}) 64 | server.Handle("/metrics", handler) 65 | _, _ = daemon.SdNotify(false, daemon.SdNotifyReady) 66 | 67 | e.server.Handler = server 68 | 69 | srvErr := make(chan error, 1) 70 | go func() { 71 | srvErr <- e.server.ListenAndServe() 72 | }() 73 | var err error 74 | // Wait for interruption. 75 | select { 76 | case err = <-srvErr: 77 | // Error when starting HTTP server. 78 | return err 79 | case <-ctx.Done(): 80 | // Wait for first CTRL+C. 81 | // Stop receiving signal notifications as soon as possible. 82 | } 83 | 84 | // When Shutdown is called, ListenAndServe immediately returns ErrServerClosed. 85 | return e.server.Shutdown(context.Background()) 86 | } 87 | 88 | func (e exporter) MustRegister(collectors ...prometheus.Collector) { 89 | e.meterRegistry.MustRegister(collectors...) 90 | } 91 | func (e exporter) Register(collector prometheus.Collector) error { 92 | return e.meterRegistry.Register(collector) 93 | } 94 | func (e exporter) Unregister(collector prometheus.Collector) bool { 95 | return e.meterRegistry.Unregister(collector) 96 | } 97 | func (e exporter) AddLivenessCheck(name string, check healthcheck.Check) { 98 | e.health.AddLivenessCheck(name, check) 99 | } 100 | func (e exporter) AddReadinessCheck(name string, check healthcheck.Check) { 101 | e.health.AddReadinessCheck(name, check) 102 | } 103 | -------------------------------------------------------------------------------- /pkg/metrics/fake/exporterMocks.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Code generated by MockGen. DO NOT EDIT. 16 | // Source: exporter.go 17 | 18 | // Package fake is a generated GoMock package. 19 | package fake 20 | 21 | import ( 22 | context "context" 23 | reflect "reflect" 24 | 25 | gomock "github.com/golang/mock/gomock" 26 | healthcheck "github.com/heptiolabs/healthcheck" 27 | prometheus "github.com/prometheus/client_golang/prometheus" 28 | ) 29 | 30 | // MockExporter is a mock of Exporter interface. 31 | type MockExporter struct { 32 | ctrl *gomock.Controller 33 | recorder *MockExporterMockRecorder 34 | } 35 | 36 | // MockExporterMockRecorder is the mock recorder for MockExporter. 37 | type MockExporterMockRecorder struct { 38 | mock *MockExporter 39 | } 40 | 41 | // NewMockExporter creates a new mock instance. 42 | func NewMockExporter(ctrl *gomock.Controller) *MockExporter { 43 | mock := &MockExporter{ctrl: ctrl} 44 | mock.recorder = &MockExporterMockRecorder{mock} 45 | return mock 46 | } 47 | 48 | // EXPECT returns an object that allows the caller to indicate expected use. 49 | func (m *MockExporter) EXPECT() *MockExporterMockRecorder { 50 | return m.recorder 51 | } 52 | 53 | // AddLivenessCheck mocks base method. 54 | func (m *MockExporter) AddLivenessCheck(name string, check healthcheck.Check) { 55 | m.ctrl.T.Helper() 56 | m.ctrl.Call(m, "AddLivenessCheck", name, check) 57 | } 58 | 59 | // AddLivenessCheck indicates an expected call of AddLivenessCheck. 60 | func (mr *MockExporterMockRecorder) AddLivenessCheck(name, check interface{}) *gomock.Call { 61 | mr.mock.ctrl.T.Helper() 62 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddLivenessCheck", reflect.TypeOf((*MockExporter)(nil).AddLivenessCheck), name, check) 63 | } 64 | 65 | // AddReadinessCheck mocks base method. 66 | func (m *MockExporter) AddReadinessCheck(name string, check healthcheck.Check) { 67 | m.ctrl.T.Helper() 68 | m.ctrl.Call(m, "AddReadinessCheck", name, check) 69 | } 70 | 71 | // AddReadinessCheck indicates an expected call of AddReadinessCheck. 72 | func (mr *MockExporterMockRecorder) AddReadinessCheck(name, check interface{}) *gomock.Call { 73 | mr.mock.ctrl.T.Helper() 74 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddReadinessCheck", reflect.TypeOf((*MockExporter)(nil).AddReadinessCheck), name, check) 75 | } 76 | 77 | // MustRegister mocks base method. 78 | func (m *MockExporter) MustRegister(collectors ...prometheus.Collector) { 79 | m.ctrl.T.Helper() 80 | varargs := []interface{}{} 81 | for _, a := range collectors { 82 | varargs = append(varargs, a) 83 | } 84 | m.ctrl.Call(m, "MustRegister", varargs...) 85 | } 86 | 87 | // MustRegister indicates an expected call of MustRegister. 88 | func (mr *MockExporterMockRecorder) MustRegister(collectors ...interface{}) *gomock.Call { 89 | mr.mock.ctrl.T.Helper() 90 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MustRegister", reflect.TypeOf((*MockExporter)(nil).MustRegister), collectors...) 91 | } 92 | 93 | // Register mocks base method. 94 | func (m *MockExporter) Register(collector prometheus.Collector) error { 95 | m.ctrl.T.Helper() 96 | ret := m.ctrl.Call(m, "Register", collector) 97 | ret0, _ := ret[0].(error) 98 | return ret0 99 | } 100 | 101 | // Register indicates an expected call of Register. 102 | func (mr *MockExporterMockRecorder) Register(collector interface{}) *gomock.Call { 103 | mr.mock.ctrl.T.Helper() 104 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Register", reflect.TypeOf((*MockExporter)(nil).Register), collector) 105 | } 106 | 107 | // Run mocks base method. 108 | func (m *MockExporter) Run(ctx context.Context) error { 109 | m.ctrl.T.Helper() 110 | ret := m.ctrl.Call(m, "Run", ctx) 111 | ret0, _ := ret[0].(error) 112 | return ret0 113 | } 114 | 115 | // Run indicates an expected call of Run. 116 | func (mr *MockExporterMockRecorder) Run(ctx interface{}) *gomock.Call { 117 | mr.mock.ctrl.T.Helper() 118 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Run", reflect.TypeOf((*MockExporter)(nil).Run), ctx) 119 | } 120 | 121 | // Unregister mocks base method. 122 | func (m *MockExporter) Unregister(collector prometheus.Collector) bool { 123 | m.ctrl.T.Helper() 124 | ret := m.ctrl.Call(m, "Unregister", collector) 125 | ret0, _ := ret[0].(bool) 126 | return ret0 127 | } 128 | 129 | // Unregister indicates an expected call of Unregister. 130 | func (mr *MockExporterMockRecorder) Unregister(collector interface{}) *gomock.Call { 131 | mr.mock.ctrl.T.Helper() 132 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Unregister", reflect.TypeOf((*MockExporter)(nil).Unregister), collector) 133 | } 134 | -------------------------------------------------------------------------------- /pkg/utils/file.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package utils 16 | 17 | import ( 18 | "io" 19 | "log/slog" 20 | ) 21 | 22 | func Close(closer io.Closer) { 23 | err := closer.Close() 24 | if err != nil { 25 | slog.Warn("Can not close stream: " + err.Error()) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /scripts/defaultGaConfig.yaml: -------------------------------------------------------------------------------- 1 | # Dummy configuration for a single metric 2 | Connection: 3 | Type: "Tunnel" 4 | Endpoint: "192.168.1.15:3671" 5 | PhysicalAddress: 2.0.1 6 | MetricsPrefix: knx_ 7 | AddressConfigs: 8 | 0/0/1: 9 | Name: dummy_metric 10 | DPT: 1.* 11 | Export: true 12 | MetricType: "counter" 13 | ReadActive: true 14 | MaxAge: 10m 15 | Comment: dummy comment 16 | Labels: 17 | room: office 18 | -------------------------------------------------------------------------------- /scripts/docker/etc_passwd: -------------------------------------------------------------------------------- 1 | nonroot:x:1337:1337:nonroot:/nonroot:/usr/sbin/nologin 2 | -------------------------------------------------------------------------------- /scripts/postinstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Copyright © 2022-2025 Christian Fritz 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | cleanInstall() { 17 | printf "\033[32m Post Install of an clean install\033[0m\n" 18 | # Step 3 (clean install), enable the service in the proper way for this platform 19 | echo "# Remove this file to allow auto starts of the knx-exporter daemon through systemd.\n" >/etc/knx-exporter/knx-exporter_not_to_be_run 20 | 21 | printf "\033[32m Reload the service unit from disk\033[0m\n" 22 | systemctl daemon-reload || : 23 | printf "\033[32m Unmask the service\033[0m\n" 24 | systemctl unmask knx-exporter.service || : 25 | printf "\033[32m Set the preset flag for the service unit\033[0m\n" 26 | systemctl preset knx-exporter.service || : 27 | printf "\033[32m Set the enabled flag for the service unit\033[0m\n" 28 | systemctl enable knx-exporter.service || : 29 | systemctl restart knx-exporter.service || : 30 | } 31 | 32 | upgrade() { 33 | printf "\033[32m Post Install of an upgrade\033[0m\n" 34 | systemctl daemon-reload || : 35 | systemctl restart knx-exporter.service || : 36 | } 37 | 38 | # Step 2, check if this is a clean install or an upgrade 39 | action="$1" 40 | if [ "$1" = "configure" ] && [ -z "$2" ]; then 41 | # Alpine linux does not pass args, and deb passes $1=configure 42 | action="install" 43 | elif [ "$1" = "configure" ] && [ -n "$2" ]; then 44 | # deb passes $1=configure $2= 45 | action="upgrade" 46 | fi 47 | 48 | case "$action" in 49 | "1" | "install") 50 | cleanInstall 51 | ;; 52 | "2" | "upgrade") 53 | printf "\033[32m Post Install of an upgrade\033[0m\n" 54 | upgrade 55 | ;; 56 | *) 57 | # $1 == version being installed 58 | printf "\033[32m Alpine\033[0m" 59 | cleanInstall 60 | ;; 61 | esac 62 | -------------------------------------------------------------------------------- /scripts/preremove.sh: -------------------------------------------------------------------------------- 1 | # Copyright © 2022-2025 Christian Fritz 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | systemctl disable knx-exporter.service || true 16 | systemctl stop knx-exporter.service || true 17 | systemctl daemon-reload || true 18 | -------------------------------------------------------------------------------- /scripts/systemd/knx-exporter.env: -------------------------------------------------------------------------------- 1 | LOG_LEVEL=info 2 | LOG_FORMAT=text 3 | CONFIG_PATH=/etc/knx-exporter/ga-config.yaml 4 | PORT=8080 5 | RESTART_POLICY=exit 6 | -------------------------------------------------------------------------------- /scripts/systemd/knx-exporter.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=KNX Prometheus exporter 3 | After=network.target 4 | ConditionPathExists=!/etc/knx-exporter/knx-exporter_not_to_be_run 5 | [Service] 6 | EnvironmentFile=-/etc/default/knx-exporter 7 | User=root 8 | Type=simple 9 | Restart=on-failure 10 | RestartSec=10 11 | ExecStart=/usr/bin/knx-exporter run --log_level $LOG_LEVEL --log_format $LOG_FORMAT -f $CONFIG_PATH -r $RESTART_POLICY -p $PORT 12 | KillMode=process 13 | 14 | [Install] 15 | WantedBy=multi-user.target 16 | Alias=knx-exporter.service 17 | 18 | [Install] 19 | WantedBy=multi-user.target 20 | Alias=knx-exporter.service 21 | -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chr-fritz/knx-exporter/ae23178949ae29efe3e95432f8e12b86038e942a/sonar-project.properties -------------------------------------------------------------------------------- /version/version.go: -------------------------------------------------------------------------------- 1 | // Copyright © 2020-2025 Christian Fritz 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package version 16 | 17 | var Version = "0.0.0-SNAPSHOT" 18 | var Revision = "0000000" 19 | var Branch = "none" 20 | var CommitDate = "0" 21 | --------------------------------------------------------------------------------