├── .github └── workflows │ └── deploy-image.yml ├── .gitignore ├── Dockerfile ├── Dockerfile-Alpine ├── README.md ├── application.yaml.example ├── helm └── tibber-pulse-reader │ ├── Chart.yaml │ ├── templates │ ├── NOTES.txt │ ├── _helpers.tpl │ └── deployment.yaml │ └── values.yaml ├── pom.xml └── src ├── main ├── java │ └── de │ │ └── wyraz │ │ ├── sml │ │ ├── AbstractSMLObject.java │ │ ├── CRC16.java │ │ ├── SMLGetListResponse.java │ │ ├── SMLListEntry.java │ │ ├── SMLMessage.java │ │ ├── SMLMessageBody.java │ │ ├── SMLMessageParser.java │ │ ├── SMLPublicCloseResponse.java │ │ ├── SMLPublicOpenResponse.java │ │ ├── SMLTime.java │ │ └── asn1 │ │ │ └── ASN1BERTokenizer.java │ │ └── tibberpulse │ │ ├── TibberPulseReader.java │ │ ├── sink │ │ ├── IMeterDataPublisher.java │ │ ├── MQTTPublisher.java │ │ ├── MeterDataHandler.java │ │ ├── MeterReadingFilter.java │ │ ├── OpenmetricsBuilder.java │ │ └── OpenmetricsPushPublisher.java │ │ ├── sml │ │ ├── ObisNameMap.java │ │ ├── SMLDecoder.java │ │ └── SMLMeterData.java │ │ ├── source │ │ ├── MQTTSource.java │ │ ├── PayloadEncoding.java │ │ ├── TibberPulseHttpReader.java │ │ └── TibberPulseSourceConfig.java │ │ └── util │ │ └── ByteUtil.java └── resources │ └── application.yaml └── test └── java └── de └── wyraz ├── sml └── asn1 │ └── ASN1NumberDecoderTest.java └── tibberpulse ├── sink └── MeterReadingFilterTests.java └── sml ├── LibSMLTests.java └── SMLDecoderTests.java /.github/workflows/deploy-image.yml: -------------------------------------------------------------------------------- 1 | # derived from https://docs.github.com/en/packages/managing-github-packages-using-github-actions-workflows/publishing-and-installing-a-package-with-github-actions 2 | name: Create and publish Docker image 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - v* 9 | 10 | env: 11 | REGISTRY: ghcr.io 12 | IMAGE_NAME: ${{ github.repository_owner }}/tibber-pulse-reader 13 | 14 | jobs: 15 | build-and-push-image: 16 | runs-on: ubuntu-latest 17 | permissions: 18 | contents: read 19 | packages: write 20 | 21 | steps: 22 | - name: Checkout repository 23 | uses: actions/checkout@v3 24 | 25 | - name: Log in to the Container registry 26 | uses: docker/login-action@v2 27 | with: 28 | registry: ${{ env.REGISTRY }} 29 | username: ${{ github.actor }} 30 | password: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | - name: Extract metadata (tags, labels) for Docker 33 | id: meta 34 | uses: docker/metadata-action@v4 35 | with: 36 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 37 | 38 | - name: Build with Maven 39 | run: mvn --batch-mode --update-snapshots package 40 | 41 | # - name: Upload jar to mega.nz 42 | # uses: Difegue/action-megacmd@043c6d2a167af3ae0904fc88c6b2829e4140dc8d 43 | # with: 44 | # args: put target/tibber-pulse-reader-1.0.0-SNAPSHOT.jar /tibber-pulse-reader/tibber-pulse-reader.${{ github.ref_name }}.jar 45 | # env: 46 | # USERNAME: ${{ secrets.MEGA_USERNAME }} 47 | # PASSWORD: ${{ secrets.MEGA_PASSWORD }} 48 | 49 | - name: Set up QEMU 50 | uses: docker/setup-qemu-action@v2 51 | 52 | - name: Set up Docker Buildx 53 | uses: docker/setup-buildx-action@v2 54 | 55 | - name: Build and push Docker image 56 | uses: docker/build-push-action@v4 57 | with: 58 | platforms: linux/amd64,linux/arm64 59 | context: . 60 | file: ./Dockerfile 61 | push: true 62 | tags: ${{ steps.meta.outputs.tags }}, ${{ env.REGISTRY }}/${{ github.repository }}:latest 63 | labels: ${{ steps.meta.outputs.labels }} 64 | 65 | - name: Build and push Docker image for Alpine 66 | uses: docker/build-push-action@v4 67 | with: 68 | platforms: linux/amd64 69 | context: . 70 | file: ./Dockerfile-Alpine 71 | push: true 72 | tags: ${{ steps.meta.outputs.tags }}-alpine 73 | labels: ${{ steps.meta.outputs.labels }} 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target/ 2 | /.classpath 3 | /.project 4 | /.settings/ 5 | /application.yaml 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM openjdk:17-bullseye 2 | 3 | ADD target/tibber-pulse-reader-1.0.0-SNAPSHOT.jar /tibber-pulse-reader.jar 4 | 5 | ENTRYPOINT ["java","-XX:+UnlockExperimentalVMOptions","-XX:+UseContainerSupport","-jar","/tibber-pulse-reader.jar"] 6 | -------------------------------------------------------------------------------- /Dockerfile-Alpine: -------------------------------------------------------------------------------- 1 | FROM openjdk:17-alpine 2 | 3 | ADD target/tibber-pulse-reader-1.0.0-SNAPSHOT.jar /tibber-pulse-reader.jar 4 | 5 | ENTRYPOINT ["java","-XX:+UnlockExperimentalVMOptions","-XX:+UseContainerSupport","-jar","/tibber-pulse-reader.jar"] 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tibber Pulse Reader 2 | 3 | > :warning: **Not maintained anymore!** I have integrated the code into https://github.com/micw/homedatabroker. New features are added there. 4 | 5 | This tool reads out data from a Tibber Pulse locally (without roundtrip trough the cloud). 6 | 7 | This is an early version with poor documentation and a lot work-in-progress. 8 | 9 | ## Configuration 10 | 11 | * configure via environment variables (ideal to run in docker) 12 | * configure via `application.yaml` config file 13 | * see `application.yaml.example` for examples of config variables 14 | 15 | ## Running (docker) 16 | 17 | ``` 18 | docker run -it --rm \ 19 | -e "TIBBER_PULSE_SOURCE=http" \ 20 | [...] 21 | ghcr.io/micw/tibber-pulse-reader:master 22 | 23 | ``` 24 | 25 | * pass all config parameters as environment variables 26 | * alternatively, create an `application.yaml` and mount it to the docker container at `/application.yaml` 27 | 28 | 29 | To fetch the latest docker image, run: 30 | 31 | ``` 32 | docker pull ghcr.io/micw/tibber-pulse-reader:master 33 | ``` 34 | 35 | # Running (native) 36 | 37 | > **⚠ jars are not yet available for download** 38 | 39 | Pre-built jars can be downloaded from https://mega.nz/folder/F6x0WKjB#AIfMjKHa5gU_aWJEyhrP3w . To run it, you need a Java Runtime Environment (JRE) with version 11 or higher installed. Config can be passed as environment variables or by creating appliucation.yaml in the working directory (e.g. next to the downloaded jar file). 40 | 41 | Example: 42 | 43 | echo "TIBBER_PULSE_SOURCE: http" > application.yaml 44 | echo "TIBBER_PULSE_HOST: tibberbridge.localnet" >> application.yaml 45 | [...] 46 | java -Xmx25M -jar tibber-pulse-reader.master.jar 47 | 48 | Memory assignment of the process can be tuned by the -Xmx option - adjust it to your needs so that the process does not get an out of memory error. 49 | 50 | ## Modes of access 51 | 52 | ### HTTP access 53 | 54 | This access method allows to read data from an almost unmodified Tibber Pulse Bridge. Only the web interface must be enabled. A description how this can be done is in my blog article https://blog.wyraz.de/allgemein/a-brief-analysis-of-the-tibber-pulse-bridge/ . 55 | 56 | Configuration parameters: 57 | 58 | * `TIBBER_PULSE_SOURCE=http` (required) - enable the HTTP based access to Tibber Pulse Bridge 59 | * `TIBBER_PULSE_HOST` (required) - Hostname or IP address of the Tibber Pulse Bridge 60 | * `TIBBER_PULSE_NODE_ID` (default 1) - ID of the connected node (Tibber Pulse IR) - only required if more than one node is connected to the Tibber Pulse Bridge 61 | * `TIBBER_PULSE_USERNAME` (optional) - Username to access the Tibber Pulse Bridge (usually `admin`) 62 | * `TIBBER_PULSE_PASSWORD` (optional) - Password to access the Tibber Pulse Bridge (the initial password is printed on the QR-code of your Tibber Pulse Gateway) 63 | * `TIBBER_PULSE_CRON` (default `*/15 * * * * *`) - Cron expression how often the data should be read from Tibber Pulse Bridge. The default is every 15 seconds **A cron expression starting with an asterisk must be quoted when passed in a YAML file, otherwise the YAML syntax is invalid** 64 | 65 | ### MQTT access 66 | 67 | To access the MQTT data, the Tibber Pulse Bridge must be modified to talk to a local MQTT server. The local MQTT server must be configured to bridge to tibber's MQTT server. The process is described in https://github.com/MSkjel/LocalPulse2Tibber . 68 | 69 | Configuration parameters: 70 | 71 | * `TIBBER_PULSE_SOURCE=mqtt` (required) - enable the MQTT based access to Tibber Pulse Bridge 72 | * `MQTT_SOURCE_HOST` (required) - Hostname or IP address of the MQTT server to subscribe to 73 | * `MQTT_SOURCE_TLS` (default `false`) - set to true to use TLS/SSL for MQTT connection 74 | * `MQTT_SOURCE_PORT` (default `1883`) - Port of the MQTT server 75 | * `MQTT_SOURCE_USERNAME` (optional) - username for authentification if required by the MQTT server 76 | * `MQTT_SOURCE_PASSWORD` (optional) - password for authentification if required by the MQTT server 77 | * `MQTT_SOURCE_TOPIC` (required) - The topic to subscribe to and read SML from 78 | * `MQTT_SOURCE_PAYLOAD_ENCODING` (default `HEX`) - Encoding of the payload. Supported values: `HEX`, `BINARY` 79 | 80 | ## Publishers 81 | 82 | Publishers sends the decoded data to other systems like databases or message brokers. The followng publishers are available. 83 | 84 | ### MQTT publisher 85 | 86 | Publishes meter readings via MQTT. Each value is published on a separate topic. The topic and payload can be configured using placeholders. 87 | 88 | Configuration parameters: 89 | 90 | * `PUBLISH_MQTT_ENABLED` (required) - set to `true` to enable publishing over MQTT 91 | * `PUBLISH_MQTT_HOST` (required) - Hostname or IP address of the MQTT server to publish to 92 | * `PUBLISH_MQTT_TLS` (default `false`) - set to true to use TLS/SSL for MQTT connection 93 | * `PUBLISH_MQTT_PORT` (default 1883) - Port of the MQTT server 94 | * `PUBLISH_MQTT_USERNAME` (optional) - username for authentification if required by the MQTT server 95 | * `PUBLISH_MQTT_PASSWORD` (optional) - password for authentification if required by the MQTT server 96 | * `PUBLISH_MQTT_TOPIC`(default `{meterId}/{nameOrObisCode}`) - a template to build the topic to publish to. See below for allowed placeholders. 97 | * `PUBLISH_MQTT_VALUE`(default `{value}`) - a template to build the payload to publish. See below for allowed placeholders. 98 | 99 | #### Placeholders for topic and payload 100 | 101 | * `{meterId}`: The id of the meter like '01EBZ0123456789' or 'unknown' if no meter ID is received 102 | * `{obisCode}`: The OBIS code of the reading 103 | * `{name}`: A friendly name of the OBIS code like 'energyImportTotal' or blank if no name is known 104 | * `{nameOrObisCode}`: A friendly name of the OBIS code or the OBIS code if the name is not known 105 | * `{value}`: The numeric value of the reading 106 | * `{unit}`: The unit of the reading or blank if there is no unit 107 | 108 | ### OpenMetrics publisher 109 | 110 | Publishes metrics in [OpenMetrics format](https://github.com/OpenObservability/OpenMetrics/blob/main/specification/OpenMetrics.md) via HTTP. Usefull to publish to [VictoriaMetrics](https://docs.victoriametrics.com/url-examples.html#apiv1importprometheus) or [Prometheus pushgateway](https://github.com/prometheus/pushgateway/blob/master/README.md). 111 | 112 | Configuration parameters: 113 | 114 | * `PUBLISH_OPENMETRICS_ENABLED` (required) - set to `true` to enable publishing over MQTT 115 | * `PUBLISH_OPENMETRICS_URL` (required) - URL of the endpoint to publish to 116 | * `PUBLISH_OPENMETRICS_USERNAME` (optional) - username for HTTP basic authentification 117 | * `PUBLISH_OPENMETRICS_PASSWORD` (optional) - password for HTTP basic authentification 118 | 119 | 120 | ## Other configuration 121 | 122 | * `LOG_LEVEL` (default `info`) - Log level to use by the application. Valid values are `debug`, `info`, `warn` and `error`. 123 | * run with `debug` to see all SML messages before/after decoding 124 | * `IGNORE_SML_CRC_ERRORS` (default `false`) - If set to true, an SML message containing CRC errors will still be processed. Only usefull for testing with problematic meters. 125 | * `PUBLISH_INTERVAL` (default empty) - Can be set to a cron expression to publish only one reading per interval. 126 | 127 | ### Filtering readings 128 | 129 | The config parameter `PUBLISH_FILTERS` allows to specify a list of filters applied to readings. Multiple filters are separated by whitespace or newline. 130 | 131 | A filter consists of a field selector (OBIS code or name) and a rule name (see below). Field names an rule names are not case sensitive. 132 | 133 | Example: 134 | 135 | Input: 136 | ``` 137 | 1-0:1.8.0*255 / energyImportTotal = 123123.45 WATT_HOURS 138 | 1-0:2.8.0*255 / energyExportTotal = 345234.56 WATT_HOURS 139 | 1-0:16.7.0*255 / powerTotal = 11111.22 WATT 140 | 1-0:36.7.0*255 / powerL1 = 22333.33 WATT 141 | ``` 142 | 143 | Filters: 144 | ``` 145 | 1-0:1.8.0*255=IGNORE 146 | powerL1=IGNORE 147 | energyExportTotal=kWh 148 | powerL1=KILOWATT 149 | ``` 150 | 151 | Result: 152 | ``` 153 | 1-0:2.8.0*255 / energyExportTotal = 345.23456 KILOWATT_HOURS 154 | 1-0:16.7.0*255 / powerTotal = 11.11122 KILOWATT 155 | ``` 156 | 157 | The following rules are allowed: 158 | 159 | * `IGNORE` - the reading will not be published 160 | * `KILOWATT` - if the reading is in WATT, it will be converted to KILOWATT 161 | * `kW` - alias for `KILOWATT` 162 | * `KILOWATT_HOURS` - if the reading is in WATT_HOURS, it will be converted to KILOWATT_HOURS 163 | * `kWh` - alias for `KILOWATT_HOURS` 164 | -------------------------------------------------------------------------------- /application.yaml.example: -------------------------------------------------------------------------------- 1 | LOG_LEVEL: info 2 | 3 | TIBBER_PULSE_SOURCE: http 4 | TIBBER_PULSE_HOST: tibber_bridge.localnet 5 | TIBBER_PULSE_USERNAME: admin 6 | TIBBER_PULSE_PASSWORD: ABCD-1234 7 | 8 | # run every 15 seconds (default) 9 | # TIBBER_PULSE_CRON:*/15 * * * * * 10 | 11 | # enable and configure publishing via MQTT 12 | # PUBLISH_MQTT_ENABLED: true 13 | # PUBLISH_MQTT_HOST: mqtt.localnet 14 | # PUBLISH_MQTT_TLS: false 15 | # PUBLISH_MQTT_PORT: 1883 16 | # PUBLISH_MQTT_USERNAME: tibberdata 17 | # PUBLISH_MQTT_PASSWORD: secret 18 | # PUBLISH_MQTT_TOPIC: "{meterId}/{nameOrObisCode}" 19 | # PUBLISH_MQTT_VALUE: "{value}" 20 | 21 | # enable and configure publishing of OpenMetrics format to victoriametrics or prometheus push gateway 22 | # PUBLISH_OPENMETRICS_ENABLED: true 23 | # PUBLISH_OPENMETRICS_URL: https://vmserver.example.com/api/v1/import/prometheus 24 | # PUBLISH_OPENMETRICS_USERNAME: pushuser 25 | # PUBLISH_OPENMETRICS_PASSWORD: s3cr3t! 26 | -------------------------------------------------------------------------------- /helm/tibber-pulse-reader/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: tibber-pulse-reader 3 | 4 | type: application 5 | 6 | version: 0.1.0 7 | -------------------------------------------------------------------------------- /helm/tibber-pulse-reader/templates/NOTES.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/micw/tibber-pulse-reader/73247a11586f93787294ce33b35a11f305f17a36/helm/tibber-pulse-reader/templates/NOTES.txt -------------------------------------------------------------------------------- /helm/tibber-pulse-reader/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "tibber-pulse-reader.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 "tibber-pulse-reader.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 "tibber-pulse-reader.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "tibber-pulse-reader.labels" -}} 37 | helm.sh/chart: {{ include "tibber-pulse-reader.chart" . }} 38 | {{ include "tibber-pulse-reader.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 "tibber-pulse-reader.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "tibber-pulse-reader.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | -------------------------------------------------------------------------------- /helm/tibber-pulse-reader/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "tibber-pulse-reader.fullname" . }} 5 | labels: 6 | {{- include "tibber-pulse-reader.labels" . | nindent 4 }} 7 | spec: 8 | replicas: 1 9 | selector: 10 | matchLabels: 11 | {{- include "tibber-pulse-reader.selectorLabels" . | nindent 6 }} 12 | template: 13 | metadata: 14 | {{- with .Values.podAnnotations }} 15 | annotations: 16 | {{- toYaml . | nindent 8 }} 17 | {{- end }} 18 | labels: 19 | {{- include "tibber-pulse-reader.selectorLabels" . | nindent 8 }} 20 | spec: 21 | {{- with .Values.imagePullSecrets }} 22 | imagePullSecrets: 23 | {{- toYaml . | nindent 8 }} 24 | {{- end }} 25 | securityContext: 26 | {{- toYaml .Values.podSecurityContext | nindent 8 }} 27 | containers: 28 | - name: {{ .Chart.Name }} 29 | securityContext: 30 | {{- toYaml .Values.securityContext | nindent 12 }} 31 | image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" 32 | imagePullPolicy: {{ .Values.image.pullPolicy }} 33 | env: 34 | {{- if .Values.logLevel }} 35 | - name: LOG_LEVEL 36 | value: "{{ .Values.logLevel }}" 37 | {{- end }} 38 | 39 | - name: TIBBER_PULSE_SOURCE 40 | value: {{ .Values.pulse.source | required "pulse.source is required" }} 41 | 42 | {{- if eq .Values.pulse.source "http" }} 43 | - name: TIBBER_PULSE_HOST 44 | value: {{ .Values.pulse.host | required "pulse.host is required" }} 45 | {{- if .Values.pulse.username }} 46 | - name: TIBBER_PULSE_USERNAME 47 | value: {{ .Values.pulse.username }} 48 | - name: TIBBER_PULSE_PASSWORD 49 | value: {{ .Values.pulse.password | required "pulse.password is required if username is set" }} 50 | {{- end }} 51 | {{- if .Values.pulse.cron }} 52 | - name: TIBBER_PULSE_CRON 53 | value: "{{ .Values.pulse.cron }}" 54 | {{- end }} 55 | {{- end }} 56 | 57 | {{- if eq .Values.pulse.source "mqtt" }} 58 | - name: MQTT_SOURCE_HOST 59 | value: {{ .Values.pulse.host | required "pulse.host is required" }} 60 | {{- if .Values.pulse.username }} 61 | - name: MQTT_SOURCE_USERNAME 62 | value: {{ .Values.pulse.username }} 63 | - name: MQTT_SOURCE_PASSWORD 64 | value: {{ .Values.pulse.password | required "pulse.password is required if username is set" }} 65 | {{- end }} 66 | - name: MQTT_SOURCE_TOPIC 67 | value: {{ .Values.pulse.topic | required "pulse.topic is required" }} 68 | {{- end }} 69 | 70 | {{- if .Values.publish.filters }} 71 | - name: PUBLISH_FILTERS 72 | value: {{ .Values.publish.filters | quote }} 73 | {{- end }} 74 | 75 | {{- if .Values.publish.interval }} 76 | - name: PUBLISH_INTERVAL 77 | value: {{ .Values.publish.interval | quote }} 78 | {{- end }} 79 | 80 | {{- if .Values.publish.mqtt.enabled }} 81 | - name: PUBLISH_MQTT_ENABLED 82 | value: "true" 83 | - name: PUBLISH_MQTT_HOST 84 | value: {{ .Values.publish.mqtt.host | required "pulse.publish.mqtt is required" }} 85 | - name: PUBLISH_MQTT_PORT 86 | value: "{{ .Values.publish.mqtt.port | default 1883 }}" 87 | {{- if .Values.publish.mqtt.username }} 88 | - name: PUBLISH_MQTT_USERNAME 89 | value: {{ .Values.publish.mqtt.username }} 90 | - name: PUBLISH_MQTT_PASSWORD 91 | value: {{ .Values.publish.mqtt.password | required "publish.mqtt.password is required if username is set" }} 92 | {{- end }} 93 | {{- if .Values.publish.mqtt.topic }} 94 | - name: PUBLISH_MQTT_TOPIC 95 | value: {{ .Values.publish.mqtt.topic | quote }} 96 | {{- end }} 97 | {{- if .Values.publish.mqtt.payload }} 98 | - name: PUBLISH_MQTT_PAYLOAD 99 | value: {{ .Values.publish.mqtt.payload | quote }} 100 | {{- end }} 101 | {{- end }} 102 | 103 | {{- if .Values.publish.openmetrics.enabled }} 104 | - name: PUBLISH_OPENMETRICS_ENABLED 105 | value: "true" 106 | - name: PUBLISH_OPENMETRICS_URL 107 | value: {{ .Values.publish.openmetrics.url | required "pulse.openmetrics.url is required" }} 108 | {{- if .Values.publish.openmetrics.username }} 109 | - name: PUBLISH_OPENMETRICS_USERNAME 110 | value: {{ .Values.publish.openmetrics.username }} 111 | - name: PUBLISH_OPENMETRICS_PASSWORD 112 | value: {{ .Values.publish.openmetrics.password | required "publish.openmetrics.password is required if username is set" }} 113 | {{- end }} 114 | {{- end }} 115 | resources: 116 | {{- toYaml .Values.resources | nindent 12 }} 117 | {{- with .Values.nodeSelector }} 118 | nodeSelector: 119 | {{- toYaml . | nindent 8 }} 120 | {{- end }} 121 | {{- with .Values.affinity }} 122 | affinity: 123 | {{- toYaml . | nindent 8 }} 124 | {{- end }} 125 | {{- with .Values.tolerations }} 126 | tolerations: 127 | {{- toYaml . | nindent 8 }} 128 | {{- end }} 129 | -------------------------------------------------------------------------------- /helm/tibber-pulse-reader/values.yaml: -------------------------------------------------------------------------------- 1 | # Default values for tibber-pulse-reader. 2 | # This is a YAML-formatted file. 3 | # Declare variables to be passed into your templates. 4 | 5 | pulse: 6 | source: http 7 | # host: 192.168.1.2 8 | # username: admin 9 | # password: ABC-DEF 10 | # cron: "*/15 * * * * *" 11 | 12 | # source: mqtt 13 | # host: 192.168.1.3 14 | # username: mqttuser 15 | # password: mqttpass 16 | # topic: values/sml 17 | 18 | publish: 19 | # filters: | 20 | # energyImportTotal=kWh 21 | # energyExportTotal=kWh 22 | # energyImportTariff1=ignore 23 | # energyImportTariff2=ignore 24 | # interval: "0/15 * * * * *" 25 | mqtt: 26 | enabled: false 27 | #host: 192.168.1.3 28 | #port: 1883 29 | #username: user 30 | #password: pass 31 | #topic: "{meterId}/{nameOrObisCode}" 32 | #payload: "{value}" 33 | openmetrics: 34 | enabled: false 35 | #url: https://vmserver.example.com/api/v1/import/prometheus 36 | #username: user 37 | #password: pass 38 | 39 | #logLevel: debug 40 | 41 | image: 42 | repository: ghcr.io/micw/tibber-pulse-reader 43 | pullPolicy: Always 44 | tag: master 45 | 46 | nameOverride: "" 47 | fullnameOverride: "" 48 | 49 | podAnnotations: {} 50 | 51 | podSecurityContext: {} 52 | # fsGroup: 2000 53 | 54 | securityContext: {} 55 | # capabilities: 56 | # drop: 57 | # - ALL 58 | # readOnlyRootFilesystem: true 59 | # runAsNonRoot: true 60 | # runAsUser: 1000 61 | 62 | resources: 63 | # We usually recommend not to specify default resources and to leave this as a conscious 64 | # choice for the user. This also increases chances charts run on environments with little 65 | # resources, such as Minikube. If you do want to specify resources, uncomment the following 66 | # lines, adjust them as necessary, and remove the curly braces after 'resources:'. 67 | limits: 68 | # cpu: 100m 69 | memory: 100Mi 70 | requests: 71 | # cpu: 100m 72 | memory: 100Mi 73 | 74 | nodeSelector: {} 75 | 76 | tolerations: [] 77 | 78 | affinity: {} 79 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 4.0.0 3 | de.wyraz 4 | tibber-pulse-reader 5 | 1.0.0-SNAPSHOT 6 | 7 | org.springframework.boot 8 | spring-boot-starter-parent 9 | 2.5.14 10 | 11 | 12 | 13 | 11 14 | de.wyraz.tibberpulse.TibberPulseReader 15 | 16 | 17 | 18 | org.springframework.boot 19 | spring-boot-starter 20 | 21 | 22 | org.apache.httpcomponents 23 | httpclient 24 | 25 | 26 | org.apache.commons 27 | commons-lang3 28 | 29 | 30 | org.openmuc 31 | jsml 32 | 1.1.2 33 | 34 | 35 | org.eclipse.paho 36 | org.eclipse.paho.client.mqttv3 37 | 1.2.0 38 | 39 | 40 | org.bouncycastle 41 | bcprov-jdk18on 42 | 1.72 43 | 44 | 45 | 46 | 47 | org.assertj 48 | assertj-core 49 | test 50 | 51 | 52 | junit 53 | junit 54 | test 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | org.springframework.boot 63 | spring-boot-maven-plugin 64 | 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /src/main/java/de/wyraz/sml/AbstractSMLObject.java: -------------------------------------------------------------------------------- 1 | package de.wyraz.sml; 2 | 3 | import java.lang.reflect.Field; 4 | import java.util.List; 5 | 6 | import de.wyraz.tibberpulse.util.ByteUtil; 7 | 8 | public abstract class AbstractSMLObject { 9 | 10 | public String toString() { 11 | StringBuilder sb=new StringBuilder(); 12 | appendToString(sb, 0, true); 13 | return sb.toString(); 14 | } 15 | 16 | protected void appendToString(StringBuilder sb, int indent, boolean printClassName) { 17 | if (printClassName) { 18 | indentAppend(sb, indent, getClass().getSimpleName()); 19 | indent++; 20 | } 21 | for (Field field: getClass().getDeclaredFields()) { 22 | field.setAccessible(true); 23 | Object value; 24 | try { 25 | value=field.get(this); 26 | } catch (Exception ex) { 27 | value=ex.toString(); 28 | } 29 | indentAppend(sb, indent, field.getName(), value); 30 | } 31 | } 32 | 33 | protected void indentAppend(StringBuilder sb, int indent, String name, Object value) { 34 | 35 | if (value instanceof List) { 36 | List list=(List) value; 37 | indentAppend(sb, indent, name+" = List["+list.size()+"]"); 38 | indent++; 39 | for (int i=0;i> 8) ^ CRC16_TABLE[(result ^ buffer[i]) & 0xff]; 31 | } 32 | result ^= 0xffff; 33 | result = ((result & 0xff) << 8) | ((result & 0xff00) >> 8); 34 | return result; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/de/wyraz/sml/SMLGetListResponse.java: -------------------------------------------------------------------------------- 1 | package de.wyraz.sml; 2 | 3 | import java.util.List; 4 | 5 | public class SMLGetListResponse extends SMLMessageBody { 6 | 7 | protected byte[] clientId; 8 | protected byte[] serverId; 9 | protected byte[] listName; 10 | protected SMLTime actSensorTime; 11 | protected List valList; 12 | protected byte[] listSignature; 13 | protected SMLTime actGatewayTime; 14 | 15 | public byte[] getClientId() { 16 | return clientId; 17 | } 18 | public byte[] getServerId() { 19 | return serverId; 20 | } 21 | public byte[] getListName() { 22 | return listName; 23 | } 24 | public SMLTime getActSensorTime() { 25 | return actSensorTime; 26 | } 27 | public List getValList() { 28 | return valList; 29 | } 30 | public byte[] getListSignature() { 31 | return listSignature; 32 | } 33 | public SMLTime getActGatewayTime() { 34 | return actGatewayTime; 35 | } 36 | 37 | 38 | } 39 | -------------------------------------------------------------------------------- /src/main/java/de/wyraz/sml/SMLListEntry.java: -------------------------------------------------------------------------------- 1 | package de.wyraz.sml; 2 | 3 | import org.openmuc.jsml.EUnit; 4 | 5 | public class SMLListEntry extends AbstractSMLObject { 6 | 7 | protected byte[] objName; 8 | protected Long status; 9 | protected SMLTime valTime; 10 | protected EUnit valUnit; 11 | protected Number scaler; 12 | protected Object value; 13 | protected byte[] valueSignature; 14 | 15 | public byte[] getObjName() { 16 | return objName; 17 | } 18 | public Long getStatus() { 19 | return status; 20 | } 21 | public SMLTime getValTime() { 22 | return valTime; 23 | } 24 | public EUnit getValUnit() { 25 | return valUnit; 26 | } 27 | public Number getScaler() { 28 | return scaler; 29 | } 30 | public Object getValue() { 31 | return value; 32 | } 33 | public byte[] getValueSignature() { 34 | return valueSignature; 35 | } 36 | 37 | 38 | 39 | } 40 | -------------------------------------------------------------------------------- /src/main/java/de/wyraz/sml/SMLMessage.java: -------------------------------------------------------------------------------- 1 | package de.wyraz.sml; 2 | 3 | public class SMLMessage extends AbstractSMLObject { 4 | 5 | protected byte[] transactionId; 6 | protected Integer groupNo; 7 | protected Integer abortOnError; 8 | protected SMLMessageBody messageBody; 9 | protected Integer crc16Actual; 10 | protected Integer crc16Expected; 11 | protected Boolean crc16Ok; 12 | 13 | public byte[] getTransactionId() { 14 | return transactionId; 15 | } 16 | public Integer getGroupNo() { 17 | return groupNo; 18 | } 19 | public Integer getAbortOnError() { 20 | return abortOnError; 21 | } 22 | public SMLMessageBody getMessageBody() { 23 | return messageBody; 24 | } 25 | public Integer getCrc16Actual() { 26 | return crc16Actual; 27 | } 28 | public Integer getCrc16Expected() { 29 | return crc16Expected; 30 | } 31 | public Boolean getCrc16Ok() { 32 | return crc16Ok; 33 | } 34 | 35 | 36 | } 37 | 38 | -------------------------------------------------------------------------------- /src/main/java/de/wyraz/sml/SMLMessageBody.java: -------------------------------------------------------------------------------- 1 | package de.wyraz.sml; 2 | 3 | public abstract class SMLMessageBody extends AbstractSMLObject { 4 | 5 | } 6 | -------------------------------------------------------------------------------- /src/main/java/de/wyraz/sml/SMLMessageParser.java: -------------------------------------------------------------------------------- 1 | package de.wyraz.sml; 2 | 3 | import java.util.ArrayList; 4 | import java.util.Collection; 5 | import java.util.List; 6 | 7 | import org.openmuc.jsml.EUnit; 8 | import org.slf4j.Logger; 9 | import org.slf4j.LoggerFactory; 10 | 11 | import de.wyraz.sml.SMLTime.SMLTimeSecIndex; 12 | import de.wyraz.sml.asn1.ASN1BERTokenizer; 13 | import de.wyraz.sml.asn1.ASN1BERTokenizer.Type; 14 | import de.wyraz.tibberpulse.util.ByteUtil; 15 | 16 | /** 17 | * SML message parser, based on 18 | * https://www.bsi.bund.de/SharedDocs/Downloads/DE/BSI/Publikationen/TechnischeRichtlinien/TR03109/TR-03109-1_Anlage_Feinspezifikation_Drahtgebundene_LMN-Schnittstelle_Teilb.pdf?__blob=publicationFile&v=1 19 | * 20 | * References in comments are to the document above. 21 | * 22 | * License: AGPLv3 23 | * 24 | * @author mwyraz 25 | */ 26 | public class SMLMessageParser { 27 | 28 | protected final Logger log = LoggerFactory.getLogger(getClass()); 29 | 30 | public static Collection parse(byte[] payload) { 31 | return new SMLMessageParser(payload).parseSMLMessages(); 32 | } 33 | 34 | protected final ASN1BERTokenizer tokenizer; 35 | 36 | protected SMLMessageParser(byte[] payload) { 37 | tokenizer=new ASN1BERTokenizer(payload); 38 | } 39 | 40 | protected Collection parseSMLMessages() { 41 | 42 | List results=new ArrayList<>(); 43 | 44 | while (tokenizer.hasMoreData()) { 45 | 46 | tokenizer.readListOfElements(6, false); 47 | 48 | int crcStartOffset=tokenizer.getOffset(); 49 | 50 | SMLMessage result = new SMLMessage(); 51 | result.transactionId = tokenizer.readOctetString(false); 52 | result.groupNo = tokenizer.readUnsigned8(false); 53 | result.abortOnError = tokenizer.readUnsigned8(false); 54 | result.messageBody = parseSMLMessageBody(); 55 | result.crc16Actual = CRC16.getCrc16(tokenizer.getMessage(), crcStartOffset, tokenizer.getOffset()-1); 56 | result.crc16Expected = tokenizer.readUnsigned16(false); 57 | result.crc16Ok = result.crc16Actual.equals(result.crc16Expected); 58 | 59 | tokenizer.readEndOfMessage(false); 60 | 61 | results.add(result); 62 | } 63 | 64 | return results; 65 | } 66 | 67 | 68 | protected SMLMessageBody parseSMLMessageBody() { 69 | long choice = readChoice32(); 70 | 71 | if (choice == 0x00000101) { 72 | return parseSMLPublicOpenResponse(); 73 | } 74 | if (choice == 0x00000201) { 75 | return parseSMLPublicCloseResponse(); 76 | } 77 | if (choice == 0x00000701) { 78 | return parseSMLGetListResponse(); 79 | } 80 | 81 | throw new RuntimeException("Unimplemented SML message body: " + ByteUtil.int32ToHex(choice)); 82 | } 83 | 84 | protected SMLPublicOpenResponse parseSMLPublicOpenResponse() { 85 | tokenizer.readListOfElements(6, false); 86 | 87 | SMLPublicOpenResponse result = new SMLPublicOpenResponse(); 88 | result.codepage = tokenizer.readOctetString(true); 89 | result.clientId = tokenizer.readOctetString(true); 90 | result.reqFileId = tokenizer.readOctetString(true); 91 | result.serverId = tokenizer.readOctetString(true); 92 | result.refTime = parseSMLTime(true); 93 | result.smlVersion = tokenizer.readUnsigned8(true); 94 | 95 | return result; 96 | } 97 | 98 | protected SMLPublicCloseResponse parseSMLPublicCloseResponse() { 99 | tokenizer.readListOfElements(1, false); 100 | 101 | SMLPublicCloseResponse result = new SMLPublicCloseResponse(); 102 | result.globalSignature = tokenizer.readOctetString(true); 103 | 104 | return result; 105 | } 106 | 107 | protected SMLGetListResponse parseSMLGetListResponse() { 108 | 109 | tokenizer.readListOfElements(7, false); 110 | 111 | SMLGetListResponse result = new SMLGetListResponse(); 112 | result.clientId = tokenizer.readOctetString(true); 113 | result.serverId = tokenizer.readOctetString(true); 114 | result.listName = tokenizer.readOctetString(true); 115 | result.actSensorTime = parseSMLTime(true); 116 | 117 | int elementCount=tokenizer.readListOfElements(-1, false); 118 | result.valList = parseSMLList(elementCount); 119 | 120 | result.listSignature = tokenizer.readOctetString(true); 121 | result.actGatewayTime = parseSMLTime(true); 122 | 123 | return result; 124 | 125 | } 126 | 127 | protected List parseSMLList(int elementCount) { 128 | List result=new ArrayList<>(elementCount); 129 | for (int i=0;i negative number 143 | // add a "00" byte so that BigInteger sees a larger positive number 144 | byte[] bytes=new byte[length + 1]; 145 | bytes[0] = 0; 146 | System.arraycopy(data, offset, bytes, 1, length); 147 | return new BigInteger(bytes); 148 | } 149 | 150 | return new BigInteger(data, offset, length); 151 | } 152 | 153 | public static Number decodeSigned(byte[] data, int offset, int length) { 154 | 155 | if (length==1) { 156 | return data[offset]; 157 | } 158 | 159 | if (length>1 && length<=4) { 160 | return new BigInteger(data, offset, length).intValue(); 161 | } 162 | 163 | if (length>4 && length<=8) { 164 | return new BigInteger(data, offset, length).longValue(); 165 | } 166 | 167 | 168 | return new BigInteger(data, offset, length); 169 | } 170 | 171 | public boolean hasMoreData() { 172 | return offset-1 && expectedSize!=dataLength) { 226 | throw new RuntimeException("Expected "+expectedType+" of length "+expectedSize+" but found "+type.describe(typeValue, dataLength, object)); 227 | } 228 | return object; 229 | } 230 | 231 | 232 | 233 | public Type readNext() { 234 | 235 | this.typeValue = 0; 236 | this.type = Type.UNKNOWN; 237 | this.dataLength = 0; 238 | this.object = null; 239 | 240 | if (!hasMoreData()) { 241 | if (this.type == Type.END_OF_FILE) { 242 | throw new RuntimeException("Read after END_OF_FILE"); 243 | } 244 | this.type = Type.END_OF_FILE; 245 | return this.type; 246 | } 247 | 248 | byte tlField = message[offset++]; 249 | this.typeValue =(byte) (tlField & 0b01110000); 250 | 251 | if (tlField == 0x0) { 252 | this.type = Type.END_OF_MESSAGE; 253 | return this.type; 254 | } 255 | 256 | int tlLength=1; 257 | int tlAndDataLength=(tlField & 0b1111); 258 | while ((tlField & 0b10000000)!=0) { 259 | tlField = message[offset++]; 260 | tlAndDataLength = (((tlAndDataLength & 0xffffffff ) << 4) | (tlField & 0b00001111)); 261 | tlLength++; 262 | } 263 | 264 | this.dataLength=tlAndDataLength-tlLength; 265 | 266 | switch (this.typeValue) { 267 | case 0b01110000: 268 | this.type = Type.LIST; 269 | this.dataLength = tlAndDataLength; // since length is not in bytes, tlLength is not substracted 270 | break; 271 | case 0b01100000: 272 | this.type = Type.UNSIGNED; 273 | this.object = decodeUnsigned(message,offset,this.dataLength); 274 | offset+=this.dataLength; 275 | break; 276 | case 0b01010000: 277 | this.type = Type.SIGNED; 278 | this.object = decodeSigned(message,offset,this.dataLength); 279 | offset+=this.dataLength; 280 | break; 281 | case 0b00000000: 282 | if (this.dataLength==0) { 283 | this.type = Type.NULL; 284 | break; 285 | } 286 | this.type = Type.OCTET_STRING; 287 | // no "break", same 288 | default: 289 | this.object = Arrays.copyOfRange(message, offset, offset+this.dataLength); 290 | offset+=this.dataLength; 291 | break; 292 | } 293 | 294 | return this.type; 295 | } 296 | 297 | public void dump(PrintStream out) { 298 | dump(out, 0, Integer.MAX_VALUE); 299 | } 300 | 301 | protected void dump(PrintStream out, int depth, int maxElementCount) { 302 | while ((maxElementCount--)>0 && readNext()!=Type.END_OF_FILE) { 303 | 304 | for (int i=0;i rules; 133 | 134 | public MeterReadingFilter(String specs) { 135 | rules=new HashMap<>(); 136 | for (String spec: specs.split("[\\r\\n\\s]+")) { 137 | if (spec.isEmpty()) { 138 | continue; 139 | } 140 | int eqPos=spec.indexOf("="); 141 | if (eqPos<1 || eqPos==spec.length()-1) { 142 | throw new IllegalArgumentException("Invalid filter spec: "+spec); 143 | } 144 | String key=spec.substring(0,eqPos).toLowerCase(); 145 | Rule rule=Rule.find(spec.substring(eqPos+1)); 146 | 147 | rules.put(key, rule); 148 | } 149 | } 150 | 151 | public List apply(List input) { 152 | 153 | List result=new ArrayList<>(); 154 | 155 | for (SMLMeterData.Reading r: input) { 156 | Rule rule=rules.get(r.getObisCode().toLowerCase()); 157 | if (rule==null && r.getName()!=null) { 158 | rule=rules.get(r.getName().toLowerCase()); 159 | } 160 | if (rule==null) { // no filtering 161 | result.add(r); 162 | } else { 163 | r=rule.apply(r); // filter the reading 164 | if (r!=null) { 165 | result.add(r); 166 | } 167 | } 168 | 169 | } 170 | 171 | return result; 172 | } 173 | 174 | } 175 | -------------------------------------------------------------------------------- /src/main/java/de/wyraz/tibberpulse/sink/OpenmetricsBuilder.java: -------------------------------------------------------------------------------- 1 | package de.wyraz.tibberpulse.sink; 2 | 3 | import java.time.ZonedDateTime; 4 | 5 | import org.apache.commons.lang3.StringUtils; 6 | 7 | /** 8 | * Builds a metric as described in https://github.com/OpenObservability/OpenMetrics/blob/main/specification/OpenMetrics.md 9 | * @author mwyraz 10 | * 11 | */ 12 | public class OpenmetricsBuilder { 13 | 14 | protected StringBuilder sb; 15 | 16 | public OpenmetricsBuilder() { 17 | sb=new StringBuilder(); 18 | } 19 | 20 | public MetricBuilder metric(String metric) { 21 | return new MetricBuilder(metric); 22 | } 23 | 24 | protected void appendMetricLine(CharSequence line) { 25 | if (sb.length()>0) { 26 | sb.append("\n"); 27 | } 28 | sb.append(line); 29 | } 30 | 31 | public class MetricBuilder { 32 | protected StringBuilder sb; 33 | protected Long timestamp; 34 | protected boolean hasTags; 35 | protected MetricBuilder(String metric) { 36 | sb=new StringBuilder(); 37 | sb.append(metric); 38 | } 39 | 40 | public MetricBuilder timestamp(ZonedDateTime timestamp) { 41 | if (timestamp==null) { 42 | this.timestamp=null; 43 | } else { 44 | this.timestamp=timestamp.toEpochSecond(); 45 | } 46 | return this; 47 | } 48 | 49 | public MetricBuilder tag(String key, String value) { 50 | if (!StringUtils.isAnyBlank(key,value)) { 51 | if (hasTags) { 52 | sb.append(","); 53 | } else { 54 | sb.append(" {"); 55 | hasTags=true; 56 | } 57 | sb.append(key); 58 | sb.append("=\""); 59 | sb.append(value); // FIXME: proper escaping! 60 | sb.append("\""); 61 | } 62 | return this; 63 | } 64 | 65 | 66 | public OpenmetricsBuilder value(Number value) { 67 | if (value!=null) { 68 | if (hasTags) { 69 | sb.append("}"); 70 | } 71 | sb.append(" ").append(value); 72 | if (timestamp!=null) { 73 | sb.append(" ").append(timestamp); 74 | } 75 | 76 | appendMetricLine(sb); 77 | } 78 | return OpenmetricsBuilder.this; 79 | } 80 | } 81 | 82 | public String build() { 83 | String result=sb.toString(); 84 | sb.setLength(0); 85 | return result; 86 | } 87 | 88 | 89 | 90 | } 91 | -------------------------------------------------------------------------------- /src/main/java/de/wyraz/tibberpulse/sink/OpenmetricsPushPublisher.java: -------------------------------------------------------------------------------- 1 | package de.wyraz.tibberpulse.sink; 2 | 3 | import java.io.IOException; 4 | import java.nio.charset.StandardCharsets; 5 | 6 | import org.apache.commons.codec.binary.Base64; 7 | import org.apache.commons.lang3.StringUtils; 8 | import org.apache.http.client.methods.CloseableHttpResponse; 9 | import org.apache.http.client.methods.RequestBuilder; 10 | import org.apache.http.entity.StringEntity; 11 | import org.apache.http.impl.client.CloseableHttpClient; 12 | import org.apache.http.impl.client.HttpClients; 13 | import org.slf4j.Logger; 14 | import org.slf4j.LoggerFactory; 15 | import org.springframework.beans.factory.annotation.Value; 16 | import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; 17 | import org.springframework.stereotype.Service; 18 | 19 | import de.wyraz.tibberpulse.sml.SMLMeterData; 20 | import de.wyraz.tibberpulse.sml.SMLMeterData.Reading; 21 | 22 | /** 23 | * Publishes data in "OpenMetrics" format, e.g. to VictoriaMetrics (https://docs.victoriametrics.com/#how-to-import-data-in-prometheus-exposition-format) 24 | */ 25 | @Service 26 | @ConditionalOnProperty(name = "publish.openmetrics.enabled", havingValue = "true") 27 | public class OpenmetricsPushPublisher implements IMeterDataPublisher { 28 | 29 | protected final Logger log = LoggerFactory.getLogger(getClass()); 30 | 31 | protected final CloseableHttpClient http = HttpClients.createDefault(); 32 | 33 | @Value("${publish.openmetrics.url}") 34 | protected String url; 35 | 36 | @Value("${publish.openmetrics.username}") 37 | protected String username; 38 | 39 | @Value("${publish.openmetrics.password}") 40 | protected String password; 41 | 42 | 43 | @Override 44 | public void publish(SMLMeterData data) throws IOException { 45 | OpenmetricsBuilder builder=new OpenmetricsBuilder(); 46 | 47 | for (Reading reading: data.getReadings()) { 48 | if (reading.getName()==null) { // unnamed readings are not supported 49 | continue; 50 | } 51 | builder 52 | .metric(reading.getName()) 53 | .tag("meter", data.getMeterId()) 54 | .tag("unit", reading.getUnit()) 55 | .tag("obis", reading.getObisCode()) 56 | .value(reading.getValue()); 57 | } 58 | 59 | String payload=builder.build(); 60 | if (StringUtils.isBlank(payload)) { 61 | return; 62 | } 63 | 64 | RequestBuilder post = RequestBuilder.post(url); 65 | post.setEntity(new StringEntity(payload, StandardCharsets.UTF_8)); 66 | if (!StringUtils.isAnyBlank(username, password)) { 67 | post.addHeader("Authorization","Basic "+Base64.encodeBase64String((username+":"+password).getBytes())); 68 | } 69 | 70 | try (CloseableHttpResponse resp = http.execute(post.build())) { 71 | if (resp.getStatusLine().getStatusCode()<200 || resp.getStatusLine().getStatusCode()>299) { 72 | log.warn("Invalid response from openmetrics push endpoint: {}",resp.getStatusLine()); 73 | } 74 | } catch (Exception ex) { 75 | log.warn("Unable to publish data to openmetrics push endpoint",ex); 76 | } 77 | 78 | 79 | 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /src/main/java/de/wyraz/tibberpulse/sml/ObisNameMap.java: -------------------------------------------------------------------------------- 1 | package de.wyraz.tibberpulse.sml; 2 | 3 | import java.util.Map; 4 | import java.util.TreeMap; 5 | 6 | /** 7 | * https://www.bundesnetzagentur.de/DE/Beschlusskammern/BK06/BK6_83_Zug_Mess/835_mitteilungen_datenformate/Mitteilung_24/2_EDIFACT-Konsultationsdokumente/Codeliste%20der%20OBIS-Kennzahlen%20und%20Medien%202.4.pdf?__blob=publicationFile&v=1 8 | * https://www.promotic.eu/en/pmdoc/Subsystems/Comm/PmDrivers/IEC62056_OBIS.htm 9 | * https://www.dzg.de/fileadmin/dzg/content/downloads/produkte-zaehler/dvze/DZG_DVZE_Handbuch_201211.pdf 10 | */ 11 | public class ObisNameMap { 12 | 13 | protected static final Map obisNameMap = new TreeMap<>(); 14 | static { 15 | obisNameMap.put("1-0:1.8.0*255", "energyImportTotal"); 16 | obisNameMap.put("1-0:1.8.1*255", "energyImportTariff1"); 17 | obisNameMap.put("1-0:1.8.2*255", "energyImportTariff2"); 18 | obisNameMap.put("1-0:2.8.0*255", "energyExportTotal"); 19 | obisNameMap.put("1-0:2.8.1*255", "energyExportTariff1"); 20 | obisNameMap.put("1-0:2.8.2*255", "energyExportTariff2"); 21 | obisNameMap.put("1-0:16.7.0*255", "powerTotal"); 22 | obisNameMap.put("1-0:36.7.0*255", "powerL1"); 23 | obisNameMap.put("1-0:56.7.0*255", "powerL2"); 24 | obisNameMap.put("1-0:76.7.0*255", "powerL3"); 25 | obisNameMap.put("1-0:32.7.0*255", "voltageL1"); 26 | obisNameMap.put("1-0:52.7.0*255", "voltageL2"); 27 | obisNameMap.put("1-0:72.7.0*255", "voltageL3"); 28 | obisNameMap.put("1-0:31.7.0*255", "currentL1"); 29 | obisNameMap.put("1-0:51.7.0*255", "currentL2"); 30 | obisNameMap.put("1-0:71.7.0*255", "currentL3"); 31 | obisNameMap.put("1-0:81.7.1*255", "phaseAngleUL2toUL1"); 32 | obisNameMap.put("1-0:81.7.2*255", "phaseAngleUL3toUL1"); 33 | obisNameMap.put("1-0:81.7.4*255", "phaseAngleIL1toUL1"); 34 | obisNameMap.put("1-0:81.7.15*255", "phaseAngleIL2toUL2"); 35 | obisNameMap.put("1-0:81.7.26*255", "phaseAngleIL3toUL3"); 36 | obisNameMap.put("1-0:14.7.0*255", "networkFrequency"); 37 | 38 | } 39 | 40 | public static String get(String obisCode) { 41 | return obisNameMap.get(obisCode); 42 | } 43 | 44 | } 45 | 46 | -------------------------------------------------------------------------------- /src/main/java/de/wyraz/tibberpulse/sml/SMLDecoder.java: -------------------------------------------------------------------------------- 1 | package de.wyraz.tibberpulse.sml; 2 | 3 | import java.io.ByteArrayInputStream; 4 | import java.io.DataInputStream; 5 | import java.io.IOException; 6 | import java.math.BigDecimal; 7 | import java.math.BigInteger; 8 | import java.text.NumberFormat; 9 | import java.util.ArrayList; 10 | 11 | import org.apache.commons.codec.binary.Hex; 12 | import org.openmuc.jsml.structures.OctetString; 13 | import org.openmuc.jsml.transport.MessageExtractor; 14 | import org.slf4j.Logger; 15 | import org.slf4j.LoggerFactory; 16 | 17 | import de.wyraz.sml.AbstractSMLObject; 18 | import de.wyraz.sml.SMLGetListResponse; 19 | import de.wyraz.sml.SMLListEntry; 20 | import de.wyraz.sml.SMLMessage; 21 | import de.wyraz.sml.SMLMessageParser; 22 | import de.wyraz.sml.SMLMessageParser; 23 | import de.wyraz.sml.SMLPublicCloseResponse; 24 | import de.wyraz.sml.SMLPublicOpenResponse; 25 | import de.wyraz.sml.asn1.ASN1BERTokenizer; 26 | import de.wyraz.tibberpulse.sml.SMLMeterData.Reading; 27 | import de.wyraz.tibberpulse.util.ByteUtil; 28 | 29 | /** 30 | * SML Spec: https://www.bsi.bund.de/SharedDocs/Downloads/DE/BSI/Publikationen/TechnischeRichtlinien/TR03109/TR-03109-1_Anlage_Feinspezifikation_Drahtgebundene_LMN-Schnittstelle_Teilb.pdf?__blob=publicationFile&v=1 31 | * 32 | * @author mwyraz 33 | * 34 | */ 35 | public class SMLDecoder { 36 | 37 | protected static final Logger log = LoggerFactory.getLogger(SMLDecoder.class); 38 | 39 | protected static boolean DUMP_RAW_SML=false; 40 | 41 | public static SMLMeterData decode(byte[] smlPayload) throws IOException { 42 | return decode(smlPayload, true); 43 | } 44 | 45 | public static SMLMeterData decode(byte[] smlPayload, boolean failOnCorruptMessagePart) throws IOException { 46 | 47 | if (log.isDebugEnabled()) { 48 | log.debug("Parsing SML: {}",Hex.encodeHexString(smlPayload)); 49 | } 50 | 51 | byte[] messagePayload=extractMessage(smlPayload); 52 | 53 | if (DUMP_RAW_SML) { 54 | System.err.println(ByteUtil.toHex(messagePayload)); 55 | 56 | System.err.println("--- Dump start"); 57 | new ASN1BERTokenizer(messagePayload).dump(System.err); 58 | System.err.println("--- Dump end"); 59 | System.err.println(); 60 | } 61 | 62 | SMLMeterData result=new SMLMeterData(); 63 | 64 | for (SMLMessage sml: SMLMessageParser.parse(messagePayload)) { 65 | decodeSMLObject(result, sml.getMessageBody()); 66 | } 67 | 68 | return result; 69 | } 70 | 71 | protected static void decodeSMLObject(SMLMeterData result,AbstractSMLObject sml) { 72 | 73 | if (sml==null) { 74 | return; // may happen on incomplete SML message 75 | } 76 | 77 | if (sml instanceof SMLPublicCloseResponse) { 78 | // no usable data 79 | return; 80 | } 81 | 82 | if (sml instanceof SMLPublicOpenResponse) { 83 | if (result.meterId==null) { 84 | result.meterId=decodeMeterId(((SMLPublicOpenResponse)sml).getServerId()); 85 | } 86 | return; 87 | } 88 | 89 | if (sml instanceof SMLGetListResponse) { 90 | SMLGetListResponse res=(SMLGetListResponse) sml; 91 | 92 | if (result.meterId==null) { 93 | result.meterId=decodeMeterId(res.getServerId()); 94 | } 95 | // my meter has "ActSensorTime" as index of seconds - is this somehow useful? 96 | if (res.getValList()!=null) { 97 | for (SMLListEntry e: res.getValList()) { 98 | decodeSMLObject(result, e); 99 | } 100 | } 101 | return; 102 | } 103 | 104 | if (sml instanceof SMLListEntry) { 105 | SMLListEntry e=(SMLListEntry) sml; 106 | 107 | String obisCode=decodeObisCode(e.getObjName()); 108 | 109 | if (obisCode==null) { 110 | return; // may happen on incomplete SML message 111 | } 112 | 113 | // System.err.println(obisCode+" "+e.getValue()); 114 | 115 | if ("129-129:199.130.3*255".equals(obisCode)) { // manufacturer id 116 | return; 117 | } 118 | if ("1-0:0.0.9*255".equals(obisCode)) { // meter serial 119 | return; 120 | } 121 | if ("1-0:0.0.0*255".equals(obisCode)) { // property number 122 | return; 123 | } 124 | if ("1-0:96.1.0*255".equals(obisCode)) { // meter id 125 | return; 126 | } 127 | if ("1-0:96.5.0*255".equals(obisCode)) { // operation status 128 | return; 129 | } 130 | if ("1-0:96.50.1*1".equals(obisCode)) { // manufacturer id 131 | return; 132 | } 133 | if ("1-0:96.50.1*4".equals(obisCode)) { // manufacturer id 134 | return; 135 | } 136 | if ("1-0:96.50.1*4".equals(obisCode)) { // hardware version 137 | return; 138 | } 139 | if ("1-0:96.50.4*4".equals(obisCode)) { // parameter version 140 | return; 141 | } 142 | if ("1-0:96.90.2*1".equals(obisCode)) { // firmware checksum 143 | return; 144 | } 145 | if ("1-0:0.2.0*0".equals(obisCode)) { // firmware version 146 | return; 147 | } 148 | if ("1-0:97.97.0*0".equals(obisCode)) { // status register 149 | return; 150 | } 151 | if ("129-129:199.130.5*255".equals(obisCode)) { // public key 152 | return; 153 | } 154 | 155 | Reading reading=new Reading(); 156 | reading.obisCode=obisCode; 157 | reading.name=ObisNameMap.get(obisCode); 158 | reading.unit=(e.getValUnit()==null)?null:e.getValUnit().toString(); 159 | if (e.getValue()!=null) { 160 | reading.value=decodeNumber(e.getValue(),e.getScaler()); 161 | } 162 | 163 | if (result.readings==null) { 164 | result.readings=new ArrayList<>(); 165 | } 166 | result.readings.add(reading); 167 | 168 | return; 169 | } 170 | 171 | log.warn("SML object not implemented: ",sml.getClass().getName()); 172 | 173 | } 174 | 175 | public static Number decodeNumber(Object value, Number scaler) { 176 | if (value==null) { 177 | return null; 178 | } 179 | 180 | int sc=(scaler==null)?0:scaler.intValue(); 181 | 182 | if (value instanceof Byte) { 183 | byte val=(Byte)value; 184 | if (sc==0) { 185 | return val; 186 | } 187 | return new BigDecimal(val).scaleByPowerOfTen(sc); 188 | } 189 | 190 | if (value instanceof Short) { 191 | short val=(Short)value; 192 | if (sc==0) { 193 | return val; 194 | } 195 | return new BigDecimal(val).scaleByPowerOfTen(sc); 196 | } 197 | 198 | if (value instanceof Integer) { 199 | int val=(Integer) value; 200 | if (sc==0) { 201 | return val; 202 | } 203 | return new BigDecimal(val).scaleByPowerOfTen(sc); 204 | } 205 | 206 | if (value instanceof Long) { 207 | long val=(Long) value; 208 | if (sc==0) { 209 | return val; 210 | } 211 | return new BigDecimal(val).scaleByPowerOfTen(sc); 212 | } 213 | 214 | if (value instanceof BigInteger) { 215 | BigInteger val=(BigInteger) value; 216 | if (sc==0) { 217 | return val; 218 | } 219 | return new BigDecimal(val).scaleByPowerOfTen(sc); 220 | } 221 | 222 | 223 | log.warn("Number conversion not implemented: {}",value.getClass().getName()); 224 | return null; 225 | } 226 | 227 | public static String decodeObisCode(OctetString s) { 228 | return (s==null) ? null : decodeObisCode(s.getValue()); 229 | } 230 | public static String decodeObisCode(byte[] bytes) { 231 | StringBuilder sb=new StringBuilder(); 232 | sb.append(bytes[0] & 0xff); 233 | sb.append("-"); 234 | sb.append(bytes[1] & 0xff); 235 | sb.append(":"); 236 | sb.append(bytes[2] & 0xff); 237 | sb.append("."); 238 | sb.append(bytes[3] & 0xff); 239 | sb.append("."); 240 | sb.append(bytes[4] & 0xff); 241 | sb.append("*"); 242 | sb.append(bytes[5] & 0xff); 243 | return sb.toString(); 244 | } 245 | 246 | /** 247 | * https://netze.estw.de/erlangenGips/Erlangen/__attic__20210120_155237__estw1.de/Kopfnavigation/Netze/Messwesen/Messwesen/Herstelleruebergreifende-Identifikationsnummer-fuer-Messeinrichtungen.pdf 248 | */ 249 | public static String decodeMeterId(byte[] bytes) { 250 | StringBuilder sb=new StringBuilder(); 251 | // 1st byte is 09 on my meter - no idea what the meaning is 252 | sb.append(Hex.encodeHex(bytes,1,1,false)[1]); // 2nd byte is "media" 253 | 254 | sb.append((char) bytes[2]); // 3 bytes manufacturer 255 | sb.append((char) bytes[3]); 256 | sb.append((char) bytes[4]); 257 | 258 | // following byte is "version" with 2 digits 259 | NumberFormat nf=NumberFormat.getIntegerInstance(); 260 | nf.setGroupingUsed(false); 261 | nf.setMinimumIntegerDigits(2); 262 | nf.setMaximumIntegerDigits(2); 263 | sb.append(nf.format(bytes[5])); 264 | 265 | // following 4 bytes are serial with 8 digits 266 | 267 | nf.setMinimumIntegerDigits(8); 268 | nf.setMaximumIntegerDigits(8); 269 | sb.append(nf.format(new BigInteger(bytes, 6, 4))); 270 | 271 | return sb.toString(); 272 | } 273 | 274 | protected static byte[] extractMessage(byte[] smlPayload) throws IOException { 275 | try { 276 | return new MessageExtractor(new DataInputStream(new ByteArrayInputStream(smlPayload)),1000).getSmlMessage(); 277 | } catch (IOException ex) { 278 | if ("Timeout".equals(ex.getMessage())) { 279 | throw new IOException("Invalid SML payload: "+Hex.encodeHexString(smlPayload)); 280 | } 281 | throw new IOException("Invalid SML payload: "+ex.getMessage()); 282 | } 283 | } 284 | 285 | 286 | } 287 | -------------------------------------------------------------------------------- /src/main/java/de/wyraz/tibberpulse/sml/SMLMeterData.java: -------------------------------------------------------------------------------- 1 | package de.wyraz.tibberpulse.sml; 2 | 3 | import java.util.List; 4 | 5 | public class SMLMeterData { 6 | 7 | public static class Reading { 8 | protected String obisCode; 9 | protected String name; 10 | protected Number value; 11 | protected String unit; 12 | 13 | public Reading() { 14 | } 15 | 16 | public Reading(String obisCode, String name, Number value, String unit) { 17 | super(); 18 | this.obisCode = obisCode; 19 | this.name = name; 20 | this.value = value; 21 | this.unit = unit; 22 | } 23 | 24 | public String getName() { 25 | return name; 26 | } 27 | public String getObisCode() { 28 | return obisCode; 29 | } 30 | public String getUnit() { 31 | return unit; 32 | } 33 | public Number getValue() { 34 | return value; 35 | } 36 | 37 | @Override 38 | public String toString() { 39 | StringBuilder sb=new StringBuilder(); 40 | sb.append(obisCode); 41 | if (name!=null) { 42 | sb.append(" / ").append(name); 43 | } 44 | sb.append(" = ").append(value); 45 | if (unit!=null) { 46 | sb.append(" ").append(unit); 47 | } 48 | return sb.toString(); 49 | } 50 | } 51 | 52 | public SMLMeterData() { 53 | } 54 | 55 | public SMLMeterData(String meterId, List readings) { 56 | this.meterId=meterId; 57 | this.readings=readings; 58 | } 59 | 60 | 61 | protected String meterId; 62 | protected List readings; 63 | 64 | public String getMeterId() { 65 | return meterId; 66 | } 67 | public List getReadings() { 68 | return readings; 69 | } 70 | 71 | @Override 72 | public String toString() { 73 | StringBuilder sb=new StringBuilder(); 74 | sb.append("Meter ").append(meterId); 75 | if (readings!=null) { 76 | for (Reading r: readings) { 77 | sb.append("\n ").append(r); 78 | } 79 | } 80 | return sb.toString(); 81 | } 82 | 83 | } 84 | -------------------------------------------------------------------------------- /src/main/java/de/wyraz/tibberpulse/source/MQTTSource.java: -------------------------------------------------------------------------------- 1 | package de.wyraz.tibberpulse.source; 2 | 3 | import java.nio.charset.StandardCharsets; 4 | import java.util.UUID; 5 | 6 | import javax.annotation.PostConstruct; 7 | 8 | import org.apache.commons.codec.binary.Hex; 9 | import org.apache.commons.lang3.StringUtils; 10 | import org.eclipse.paho.client.mqttv3.IMqttClient; 11 | import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken; 12 | import org.eclipse.paho.client.mqttv3.MqttCallback; 13 | import org.eclipse.paho.client.mqttv3.MqttClient; 14 | import org.eclipse.paho.client.mqttv3.MqttConnectOptions; 15 | import org.eclipse.paho.client.mqttv3.MqttMessage; 16 | import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence; 17 | import org.slf4j.Logger; 18 | import org.slf4j.LoggerFactory; 19 | import org.springframework.beans.factory.annotation.Autowired; 20 | import org.springframework.beans.factory.annotation.Value; 21 | 22 | import de.wyraz.tibberpulse.sink.MeterDataHandler; 23 | import de.wyraz.tibberpulse.sml.SMLDecoder; 24 | import de.wyraz.tibberpulse.sml.SMLMeterData; 25 | 26 | public class MQTTSource { 27 | 28 | protected final Logger log = LoggerFactory.getLogger(getClass()); 29 | 30 | @Value("${sml.ignoreCrcErrors:false}") 31 | protected boolean ignoreCrcErrors; 32 | 33 | @Value("${tibber.pulse.mqtt.host}") 34 | protected String mqttHost; 35 | 36 | @Value("${tibber.pulse.mqtt.tls:false}") 37 | protected boolean mqttTls; 38 | 39 | @Value("${tibber.pulse.mqtt.port:1883}") 40 | protected int mqttPort; 41 | 42 | @Value("${tibber.pulse.mqtt.username}") 43 | protected String mqttUsername; 44 | 45 | @Value("${tibber.pulse.mqtt.password}") 46 | protected String mqttPassword; 47 | 48 | @Value("${tibber.pulse.mqtt.topic}") 49 | protected String mqttTopic; 50 | 51 | @Value("${tibber.pulse.mqtt.payloadEncoding}") 52 | protected PayloadEncoding mqttPayloadEncoding; 53 | 54 | protected IMqttClient mqttClient; 55 | 56 | @PostConstruct 57 | public void startMqttClient() throws Exception { 58 | 59 | String protocol=mqttTls?"ssl":"tcp"; 60 | 61 | mqttClient = new MqttClient(protocol+"://"+mqttHost+":"+mqttPort,UUID.randomUUID().toString(), new MemoryPersistence()); 62 | MqttConnectOptions options = new MqttConnectOptions(); 63 | options.setAutomaticReconnect(false); 64 | options.setCleanSession(true); 65 | options.setConnectionTimeout(10); 66 | if (!StringUtils.isAnyBlank(mqttUsername, mqttPassword)) { 67 | options.setUserName(mqttUsername); 68 | options.setPassword(mqttPassword.toCharArray()); 69 | } 70 | mqttClient.setCallback(new MqttCallback() { 71 | 72 | @Override 73 | public void messageArrived(String topic, MqttMessage message) throws Exception { 74 | handleMessage(topic, message); 75 | } 76 | 77 | @Override 78 | public void deliveryComplete(IMqttDeliveryToken token) { 79 | } 80 | 81 | @Override 82 | public void connectionLost(Throwable cause) { 83 | System.err.println("Disconnected"); 84 | System.exit(0); 85 | } 86 | }); 87 | 88 | mqttClient.connect(options); 89 | mqttClient.subscribe(mqttTopic); 90 | } 91 | 92 | @Autowired 93 | protected MeterDataHandler handler; 94 | 95 | protected void handleMessage(String topic, MqttMessage message) { 96 | SMLMeterData data; 97 | try { 98 | byte[] payload=mqttPayloadEncoding.decode(message.getPayload()); 99 | if (payload==null) { 100 | return; 101 | } 102 | 103 | data=SMLDecoder.decode(payload, !ignoreCrcErrors); 104 | } catch (Exception ex) { 105 | log.warn("Unable to parse SML from response",ex); 106 | return; 107 | } 108 | 109 | if (data!=null) { 110 | try { 111 | handler.publish(data); 112 | } catch (Exception ex) { 113 | log.warn("Unable publish meter data",ex); 114 | return; 115 | } 116 | } 117 | } 118 | 119 | 120 | } 121 | -------------------------------------------------------------------------------- /src/main/java/de/wyraz/tibberpulse/source/PayloadEncoding.java: -------------------------------------------------------------------------------- 1 | package de.wyraz.tibberpulse.source; 2 | 3 | import java.nio.charset.StandardCharsets; 4 | 5 | import org.apache.commons.codec.DecoderException; 6 | import org.apache.commons.codec.binary.Hex; 7 | import org.slf4j.Logger; 8 | import org.slf4j.LoggerFactory; 9 | 10 | public enum PayloadEncoding { 11 | 12 | HEX { 13 | @Override 14 | byte[] decode(byte[] payload) { 15 | try { 16 | return Hex.decodeHex(new String(payload, StandardCharsets.UTF_8)); 17 | } catch (DecoderException ex) { 18 | log.warn("Unable to decode SML as HEX",ex); 19 | return null; 20 | } 21 | } 22 | }, 23 | BINARY { 24 | @Override 25 | byte[] decode(byte[] payload) { 26 | return payload; 27 | } 28 | }, 29 | ; 30 | 31 | protected static final Logger log = LoggerFactory.getLogger(PayloadEncoding.class); 32 | 33 | 34 | abstract byte[] decode(byte[] payload); 35 | 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/de/wyraz/tibberpulse/source/TibberPulseHttpReader.java: -------------------------------------------------------------------------------- 1 | package de.wyraz.tibberpulse.source; 2 | 3 | import java.io.IOException; 4 | 5 | import org.apache.commons.codec.binary.Base64; 6 | import org.apache.commons.lang3.StringUtils; 7 | import org.apache.http.client.methods.CloseableHttpResponse; 8 | import org.apache.http.client.methods.RequestBuilder; 9 | import org.apache.http.impl.client.CloseableHttpClient; 10 | import org.apache.http.impl.client.HttpClients; 11 | import org.apache.http.util.EntityUtils; 12 | import org.slf4j.Logger; 13 | import org.slf4j.LoggerFactory; 14 | import org.springframework.beans.factory.annotation.Autowired; 15 | import org.springframework.beans.factory.annotation.Value; 16 | import org.springframework.boot.CommandLineRunner; 17 | import org.springframework.context.ConfigurableApplicationContext; 18 | import org.springframework.scheduling.annotation.Scheduled; 19 | 20 | import de.wyraz.tibberpulse.sink.MeterDataHandler; 21 | import de.wyraz.tibberpulse.sml.SMLDecoder; 22 | import de.wyraz.tibberpulse.sml.SMLMeterData; 23 | 24 | public class TibberPulseHttpReader implements CommandLineRunner { 25 | 26 | protected final Logger log = LoggerFactory.getLogger(getClass()); 27 | 28 | 29 | @Value("${sml.ignoreCrcErrors:false}") 30 | protected boolean ignoreCrcErrors; 31 | 32 | @Value("${tibber.pulse.http.url}") 33 | protected String tibberPulseUrl; 34 | 35 | @Value("${tibber.pulse.http.username}") 36 | protected String tibberPulseUsername; 37 | 38 | @Value("${tibber.pulse.http.password}") 39 | protected String tibberPulsePassword; 40 | 41 | protected final CloseableHttpClient http = HttpClients.createDefault(); 42 | 43 | @Autowired 44 | protected ConfigurableApplicationContext ctx; 45 | 46 | @Autowired 47 | protected MeterDataHandler handler; 48 | 49 | @Override 50 | public void run(String... args) throws IOException { 51 | fetch(true); 52 | } 53 | 54 | @Scheduled(cron = "${tibber.pulse.http.cron}") 55 | public void fetch() throws IOException { 56 | fetch(false); 57 | } 58 | 59 | protected void fetch(boolean shutdownOnError) throws IOException { 60 | RequestBuilder get = RequestBuilder.get(tibberPulseUrl); 61 | if (!StringUtils.isAnyBlank(tibberPulseUsername, tibberPulsePassword)) { 62 | get.addHeader("Authorization","Basic "+Base64.encodeBase64String((tibberPulseUsername+":"+tibberPulsePassword).getBytes())) ; 63 | } 64 | 65 | try (CloseableHttpResponse resp = http.execute(get.build())) { 66 | if (resp.getStatusLine().getStatusCode() != 200) { 67 | log.warn("Invalid response from tibber pulse gateway endpoint: {}",resp.getStatusLine()); 68 | if (shutdownOnError) { 69 | ctx.close(); 70 | } 71 | return; 72 | } 73 | byte[] payload; 74 | try { 75 | payload=EntityUtils.toByteArray(resp.getEntity()); 76 | 77 | if (payload.length==0) { 78 | log.debug("Received no data"); 79 | return; 80 | } 81 | 82 | } catch (Exception ex) { 83 | log.warn("Unable to extract payload from response",ex); 84 | if (shutdownOnError) { 85 | ctx.close(); 86 | } 87 | return; 88 | } 89 | 90 | SMLMeterData data; 91 | try { 92 | data=SMLDecoder.decode(payload, !ignoreCrcErrors); 93 | } catch (Exception ex) { 94 | log.warn("Unable to parse SML from response",ex); 95 | if (shutdownOnError) { 96 | ctx.close(); 97 | } 98 | return; 99 | } 100 | 101 | if (data!=null) { 102 | try { 103 | handler.publish(data); 104 | } catch (Exception ex) { 105 | log.warn("Unable publish meter data",ex); 106 | if (shutdownOnError) { 107 | ctx.close(); 108 | } 109 | return; 110 | } 111 | } 112 | 113 | } catch (Exception ex) { 114 | if (shutdownOnError) { 115 | log.warn("Unable to fetch data from tibber pulse bridge. Terminating",ex); 116 | ctx.close(); 117 | System.exit(1); 118 | } else { 119 | log.warn("Unable to fetch data from tibber pulse bridge",ex); 120 | } 121 | return; 122 | } 123 | 124 | } 125 | 126 | } 127 | -------------------------------------------------------------------------------- /src/main/java/de/wyraz/tibberpulse/source/TibberPulseSourceConfig.java: -------------------------------------------------------------------------------- 1 | package de.wyraz.tibberpulse.source; 2 | 3 | import org.springframework.beans.factory.annotation.Value; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.context.annotation.Configuration; 6 | 7 | @Configuration 8 | public class TibberPulseSourceConfig { 9 | 10 | @Bean 11 | public Object getTibberPulseSource(@Value("${tibber.pulse.source}") String source) { 12 | if ("http".equals(source)) { 13 | return new TibberPulseHttpReader(); 14 | } 15 | if ("mqtt".equals(source)) { 16 | return new MQTTSource(); 17 | } 18 | 19 | throw new IllegalArgumentException("Source most be 'http' or 'mqtt'"); 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/de/wyraz/tibberpulse/util/ByteUtil.java: -------------------------------------------------------------------------------- 1 | package de.wyraz.tibberpulse.util; 2 | 3 | public class ByteUtil { 4 | 5 | public static String toBits(byte b) { 6 | StringBuilder sb=new StringBuilder(8); 7 | for (int i=7;i>=0;i--) { 8 | sb.append((b & (1<> 4]); 22 | sb.append(HEX_CHARS[(bytes[i] & 0x0F)]); 23 | } 24 | return sb.toString(); 25 | } 26 | public static String int8ToHex(int i) { 27 | return toHex(new byte[] { 28 | (byte) (i & 0xFF) 29 | }); 30 | } 31 | public static String int16ToHex(long l) { 32 | return toHex(new byte[] { 33 | (byte) ((l >> 8) & 0xFF), 34 | (byte) (l & 0xFF) 35 | }); 36 | } 37 | public static String int24ToHex(long l) { 38 | return toHex(new byte[] { 39 | (byte) ((l >> 16) & 0xFF), 40 | (byte) ((l >> 8) & 0xFF), 41 | (byte) (l & 0xFF) 42 | }); 43 | } 44 | public static String int32ToHex(long l) { 45 | return toHex(new byte[] { 46 | (byte) ((l >> 24) & 0xFF), 47 | (byte) ((l >> 16) & 0xFF), 48 | (byte) ((l >> 8) & 0xFF), 49 | (byte) (l & 0xFF) 50 | }); 51 | } 52 | public static String int40ToHex(long l) { 53 | return toHex(new byte[] { 54 | (byte) ((l >> 32) & 0xFF), 55 | (byte) ((l >> 24) & 0xFF), 56 | (byte) ((l >> 16) & 0xFF), 57 | (byte) ((l >> 8) & 0xFF), 58 | (byte) (l & 0xFF) 59 | }); 60 | } 61 | public static String int64ToHex(long l) { 62 | return toHex(new byte[] { 63 | (byte) ((l >> 56) & 0xFF), 64 | (byte) ((l >> 48) & 0xFF), 65 | (byte) ((l >> 40) & 0xFF), 66 | (byte) ((l >> 32) & 0xFF), 67 | (byte) ((l >> 24) & 0xFF), 68 | (byte) ((l >> 16) & 0xFF), 69 | (byte) ((l >> 8) & 0xFF), 70 | (byte) (l & 0xFF) 71 | }); 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /src/main/resources/application.yaml: -------------------------------------------------------------------------------- 1 | tibber: 2 | pulse: 3 | source: ${TIBBER_PULSE_SOURCE} 4 | http: 5 | host: ${TIBBER_PULSE_HOST} 6 | nodeId: ${TIBBER_PULSE_NODE_ID:1} 7 | url: ${TIBBER_PULSE_URL:http://${tibber.pulse.http.host}/data.json?node_id=${tibber.pulse.http.nodeId}} 8 | username: ${TIBBER_PULSE_USERNAME:} 9 | password: ${TIBBER_PULSE_PASSWORD:} 10 | cron: ${TIBBER_PULSE_CRON:*/15 * * * * *} 11 | mqtt: 12 | host: "${MQTT_SOURCE_HOST}" 13 | port: "${MQTT_SOURCE_PORT:1883}" 14 | tls: "${MQTT_SOURCE_TLS:false}" 15 | username: "${MQTT_SOURCE_USERNAME:}" 16 | password: "${MQTT_SOURCE_PASSWORD:}" 17 | topic: "${MQTT_SOURCE_TOPIC}" 18 | payloadEncoding: "${MQTT_SOURCE_PAYLOAD_ENCODING:HEX}" 19 | 20 | sml: 21 | ignoreCrcErrors: ${IGNORE_SML_CRC_ERRORS:false} 22 | 23 | logging: 24 | level: 25 | ROOT: warn 26 | de.wyraz.tibberpulse: ${LOG_LEVEL:info} 27 | 28 | publish: 29 | filters: ${PUBLISH_FILTERS:} 30 | interval: "${PUBLISH_INTERVAL:}" 31 | mqtt: 32 | enabled: "${PUBLISH_MQTT_ENABLED:false}" 33 | host: "${PUBLISH_MQTT_HOST}" 34 | port: "${PUBLISH_MQTT_PORT:1883}" 35 | username: "${PUBLISH_MQTT_USERNAME:}" 36 | password: "${PUBLISH_MQTT_PASSWORD:}" 37 | topic: "${PUBLISH_MQTT_TOPIC:{meterId}/{nameOrObisCode}}" 38 | payload: "${PUBLISH_MQTT_VALUE:{value}}" 39 | openmetrics: 40 | enabled: "${PUBLISH_OPENMETRICS_ENABLED:false}" 41 | url: "${PUBLISH_OPENMETRICS_URL}" 42 | username: "${PUBLISH_OPENMETRICS_USERNAME:}" 43 | password: "${PUBLISH_OPENMETRICS_PASSWORD:}" 44 | -------------------------------------------------------------------------------- /src/test/java/de/wyraz/sml/asn1/ASN1NumberDecoderTest.java: -------------------------------------------------------------------------------- 1 | package de.wyraz.sml.asn1; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import java.math.BigInteger; 6 | 7 | import org.junit.Test; 8 | 9 | public class ASN1NumberDecoderTest { 10 | 11 | protected static Number decodeUnsigned(int... byteArray) { 12 | 13 | byte[] bytes=new byte[byteArray.length]; 14 | for (int i=0;i readings=Arrays.asList( 16 | new SMLMeterData.Reading("1-0:1.8.0*255", "energyImportTotal", 123123.45, "WATT_HOUR"), 17 | new SMLMeterData.Reading("1-0:2.8.0*255", "energyExportTotal", 345234.56, "WATT_HOUR"), 18 | new SMLMeterData.Reading("1-0:16.7.0*255", "powerTotal", 11111.22, "WATT"), 19 | new SMLMeterData.Reading("1-0:36.7.0*255", "powerL1", 22333.33, "WATT") 20 | ); 21 | 22 | @Test 23 | public void testFilterNone() { 24 | assertThat(new MeterReadingFilter("").apply(readings)) 25 | .extracting(Object::toString) 26 | .containsExactlyInAnyOrder( 27 | "1-0:1.8.0*255 / energyImportTotal = 123123.45 WATT_HOUR", 28 | "1-0:2.8.0*255 / energyExportTotal = 345234.56 WATT_HOUR", 29 | "1-0:16.7.0*255 / powerTotal = 11111.22 WATT", 30 | "1-0:36.7.0*255 / powerL1 = 22333.33 WATT" 31 | ); 32 | } 33 | 34 | @Test 35 | public void testFilterInvalid() { 36 | assertThatCode(() -> new MeterReadingFilter("somestuff")) 37 | .isInstanceOf(IllegalArgumentException.class) 38 | .hasMessage("Invalid filter spec: somestuff") 39 | ; 40 | 41 | assertThatCode(() -> new MeterReadingFilter("=somestuff")) 42 | .isInstanceOf(IllegalArgumentException.class) 43 | .hasMessage("Invalid filter spec: =somestuff") 44 | ; 45 | 46 | assertThatCode(() -> new MeterReadingFilter("somestuff=")) 47 | .isInstanceOf(IllegalArgumentException.class) 48 | .hasMessage("Invalid filter spec: somestuff=") 49 | ; 50 | 51 | 52 | assertThatCode(() -> new MeterReadingFilter("somestuff=UNKNOWN")) 53 | .isInstanceOf(IllegalArgumentException.class) 54 | .hasMessageStartingWith("Invalid filter rule: UNKNOWN. Allowed rules: [IGNORE, KILOWATT(kW)") 55 | ; 56 | 57 | } 58 | 59 | @Test 60 | public void testFilterIgnore() { 61 | assertThat(new MeterReadingFilter("PoWerToTal=IgNoRe 1-0:2.8.0*255=IGNORE").apply(readings)) 62 | .extracting(Object::toString) 63 | .containsExactlyInAnyOrder( 64 | "1-0:1.8.0*255 / energyImportTotal = 123123.45 WATT_HOUR", 65 | "1-0:36.7.0*255 / powerL1 = 22333.33 WATT" 66 | ); 67 | } 68 | 69 | @Test 70 | public void testFilterKilowatt() { 71 | assertThat(new MeterReadingFilter("powerTotal=KILOWATT").apply(readings)) 72 | .extracting(Object::toString) 73 | .containsExactlyInAnyOrder( 74 | "1-0:1.8.0*255 / energyImportTotal = 123123.45 WATT_HOUR", 75 | "1-0:2.8.0*255 / energyExportTotal = 345234.56 WATT_HOUR", 76 | "1-0:16.7.0*255 / powerTotal = 11.11122 KILOWATT", 77 | "1-0:36.7.0*255 / powerL1 = 22333.33 WATT" 78 | ); 79 | } 80 | 81 | @Test 82 | public void testFilterKW() { 83 | assertThat(new MeterReadingFilter("powerTotal=kW").apply(readings)) 84 | .extracting(Object::toString) 85 | .containsExactlyInAnyOrder( 86 | "1-0:1.8.0*255 / energyImportTotal = 123123.45 WATT_HOUR", 87 | "1-0:2.8.0*255 / energyExportTotal = 345234.56 WATT_HOUR", 88 | "1-0:16.7.0*255 / powerTotal = 11.11122 KILOWATT", 89 | "1-0:36.7.0*255 / powerL1 = 22333.33 WATT" 90 | ); 91 | } 92 | 93 | @Test 94 | public void testFilterKWWrongUnit() { 95 | assertThat(new MeterReadingFilter("energyImportTotal=kWh").apply(readings)) 96 | .extracting(Object::toString) 97 | .containsExactlyInAnyOrder( 98 | "1-0:1.8.0*255 / energyImportTotal = 123.12345 KILOWATT_HOUR", 99 | "1-0:2.8.0*255 / energyExportTotal = 345234.56 WATT_HOUR", 100 | "1-0:16.7.0*255 / powerTotal = 11111.22 WATT", 101 | "1-0:36.7.0*255 / powerL1 = 22333.33 WATT" 102 | ); 103 | } 104 | 105 | } 106 | -------------------------------------------------------------------------------- /src/test/java/de/wyraz/tibberpulse/sml/LibSMLTests.java: -------------------------------------------------------------------------------- 1 | package de.wyraz.tibberpulse.sml; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | 5 | import org.apache.commons.codec.binary.Hex; 6 | import org.junit.Ignore; 7 | import org.junit.Test; 8 | 9 | /** 10 | * Tests taken from https://github.com/devZer0/libsml-testing 11 | * @author mwyraz 12 | * 13 | */ 14 | public class LibSMLTests { 15 | 16 | static { 17 | SMLDecoder.DUMP_RAW_SML=true; 18 | } 19 | 20 | @Test 21 | public void testDZG_DVS_7412_2_jmberg() throws Exception { 22 | // DZG_DVS-7412.2_jmberg.hex 23 | // Remarks: Wrongly encoded power value 356.24 as -299.12 24 | // FIXME: check what workaround is needed here 25 | 26 | String payload="1B1B1B1B010101017605F12CAD07620062007263010176010102310B0A01445A47000282225E7262016505E748D7620263955C007605F22CAD07620062007263070177010B0A01445A47000282225E070100620AFFFF7262016505E748D77577070100603201010172620162006200520004445A470177070100600100FF017262016200620052000B0A01445A47000282225E0177070100010800FF641C01047262016200621E52FF65033C93890177070100020800FF017262016200621E52FF650FA49A9E0177070100100700FF017262016200621B52FE538B28010101636B99007605F32CAD076200620072630201710163D90C00001B1B1B1B1A01C3E1"; 27 | 28 | SMLMeterData data=SMLDecoder.decode(Hex.decodeHex(payload), false); 29 | 30 | System.err.println(data); 31 | 32 | assertThat(data).isNotNull(); 33 | 34 | assertThat(data.getMeterId()).isEqualTo("1DZG0042082910"); 35 | 36 | assertThat(data.getReadings()) 37 | .isNotNull() 38 | .extracting(Object::toString) 39 | .containsExactlyInAnyOrder( 40 | "1-0:1.8.0*255 / energyImportTotal = 5430157.7 WATT_HOUR", 41 | "1-0:2.8.0*255 / energyExportTotal = 26244572.6 WATT_HOUR", 42 | "1-0:16.7.0*255 / powerTotal = -299.12 WATT" 43 | ); 44 | } 45 | 46 | @Test 47 | public void testDZG_DVS_7420_2V_G2_mtr0() throws Exception { 48 | // DZG_DVS-7420.2V.G2_mtr0.hex 49 | 50 | String payload="1B1B1B1B0101010176057123AF01620062007263010176010102310B0A01445A4700039E2054726201648FCCB4620263379A0076057223AF01620062007263070177010B0A01445A4700039E2054070100620AFFFF726201648FCCB47577070100603201010172620162006200520004445A470177070100600100FF017262016200620052000B0A01445A4700039E20540177070100010800FF641C01047262016200621E52FF645CB0670177070100020800FF017262016200621E52FF64C149960177070100100700FF017262016200621B52FE53545F0101016308DD0076057323AF016200620072630201710163E04800001B1B1B1B1A0119671B1B1B1B0101010176057423AF01620062007263010176010102310B0A01445A4700039E2054726201648FCCB5620263EA830076057523AF"; 51 | 52 | SMLMeterData data=SMLDecoder.decode(Hex.decodeHex(payload), false); 53 | 54 | System.err.println(data); 55 | 56 | assertThat(data).isNotNull(); 57 | 58 | assertThat(data.getMeterId()).isEqualTo("1DZG0060694612"); 59 | 60 | assertThat(data.getReadings()) 61 | .isNotNull() 62 | .extracting(Object::toString) 63 | .containsExactlyInAnyOrder( 64 | "1-0:1.8.0*255 / energyImportTotal = 607447.1 WATT_HOUR", 65 | "1-0:2.8.0*255 / energyExportTotal = 1266728.6 WATT_HOUR", 66 | "1-0:16.7.0*255 / powerTotal = 215.99 WATT" 67 | ); 68 | } 69 | 70 | @Test 71 | public void testDZG_DVS_7420_2V_G2_mtr1() throws Exception { 72 | // DZG_DVS-7420.2V.G2_mtr1.hex 73 | 74 | String payload="1B1B1B1B010101017605D225AF01620062007263010176010102310B0A01445A4700039E2055726201648FCD5C6202630172007605D325AF01620062007263070177010B0A01445A4700039E2055070100620AFFFF726201648FCD5C7777070100603201010172620162006200520004445A470177070100600100FF017262016200620052000B0A01445A4700039E20550177070100010800FF641C00047262016200621E52FF65016723B90177070100010801FF017262016200621E52FF6405E4340177070100010802FF017262016200621E52FF6501613F850177070100020800FF017262016200621E52FF62000177070100100700FF017262016200621B52FE53026E01010163BA5B007605D425AF016200620072630201710163E806000000001B1B1B1B1A0311641B1B1B1B010101017605D525AF01620062007263010176010102310B0A01445A4700039E2055726201648FCD5D6202637317007605D625AF01620062007263070177010B0A01445A4700039E2055070100620AFFFF726201648FCD5D7777070100603201010172620162006200520004445A470177070100600100FF017262016200620052000B0A01445A4700039E20550177070100010800FF641C00047262016200621E52FF65016723B90177070100010801FF017262016200621E52FF6405E4340177070100010802FF017262016200621E52FF6501613F850177070100020800FF017262016200621E52FF62000177070100100700FF017262016200621B52FE530254010101635543007605D725AF016200620072630201710163D685000000001B1B1B1B1A0358AF1B1B1B1B010101017605D825AF01620062007263010176010102310B0A01445A4700039E2055726201648FCD5E620263BB41007605D925AF01620062007263070177010B0A01445A4700039E2055070100620AFFFF726201648FCD5E7777070100603201010172620162006200520004445A470177070100600100FF017262016200620052000B0A01445A4700039E20550177070100010800FF641C00047262016200621E52FF65016723B90177070100010801FF017262016200621E52FF6405E4340177070100010802FF017262016200621E52FF6501613F850177070100020800FF017262016200621E52FF62000177070100100700FF017262016200621B52FE53026D010101630067007605DA25AF016200620072630201710163E6E7000000001B1B1B1B1A0314151B1B1B1B010101017605DB25AF01620062007263010176010102310B0A01445A4700039E2055726201648FCD5F62026397DD007605DC25AF01620062007263070177010B0A01445A4700039E2055070100620AFFFF726201648FCD5F7777070100603201010172620162006200520004445A470177070100600100FF017262016200620052000B0A01445A4700039E20550177070100010800FF641C00047262016200621E52FF65016723B90177070100010801FF017262016200621E52FF6405E4340177070100010802FF017262016200621E52FF6501613F850177070100020800FF017262016200621E52FF62000177070100100700FF017262016200621B52FE53024301010163A2CE007605DD25AF0162006200726302017101636197000000001B1B1B1B1A035AC11B1B1B1B010101017605DE25AF01620062007263010176010102310B0A01445A4700039E2055726201648FCD60620263FF52007605DF25AF01620062007263070177010B0A01445A4700039E205507010062"; 75 | 76 | SMLMeterData data=SMLDecoder.decode(Hex.decodeHex(payload), false); 77 | 78 | System.err.println(data); 79 | 80 | assertThat(data).isNotNull(); 81 | 82 | assertThat(data.getMeterId()).isEqualTo("1DZG0060694613"); 83 | 84 | assertThat(data.getReadings()) 85 | .isNotNull() 86 | .extracting(Object::toString) 87 | .containsExactlyInAnyOrder( 88 | "1-0:1.8.0*255 / energyImportTotal = 2353656.9 WATT_HOUR", 89 | "1-0:1.8.1*255 / energyImportTariff1 = 38610.0 WATT_HOUR", 90 | "1-0:1.8.2*255 / energyImportTariff2 = 2315046.9 WATT_HOUR", 91 | "1-0:2.8.0*255 / energyExportTotal = 0.0 WATT_HOUR", 92 | "1-0:16.7.0*255 / powerTotal = 6.22 WATT" 93 | ); 94 | } 95 | 96 | @Test 97 | public void testDZG_DVS_7420_2V_G2_mtr2() throws Exception { 98 | // DZG_DVS-7420.2V.G2_mtr2.hex 99 | 100 | String payload="1B1B1B1B010101017605CA24AF01620062007263010176010102310B0A01445A4700039E2053726201648FCC6F6202630B44007605CB24AF01620062007263070177010B0A01445A4700039E2053070100620AFFFF726201648FCC6F7577070100603201010172620162006200520004445A470177070100600100FF017262016200620052000B0A01445A4700039E20530177070100010800FF641C00047262016200621E52FF640200E80177070100020800FF017262016200621E52FF64E4D65D0177070100100700FF017262016200621B52FE5302A501010163720B007605CC24AF016200620072630201710163F13A00001B1B1B1B1A01AAE41B1B1B1B010101017605CD24AF01620062007263010176010102310B0A01445A4700039E2053726201648FCC70620263F7B4007605CE24AF01620062007263070177010B0A01445A4700039E2053070100620AFFFF726201648FCC707577070100603201010172620162006200520004445A470177070100600100FF017262016200620052000B0A01445A4700039E20530177070100010800FF641C00047262016200621E52FF640200E80177070100020800FF017262016200621E52FF64E4D65D0177070100100700FF017262016200621B52FE5302900101016330C3007605CF24AF016200620072630201710163CFB900001B1B1B1B1A013DD31B1B1B1B010101017605D024AF01620062007263010176010102310B0A01445A4700039E2053726201648FCC71620263CCAA007605D124AF01620062007263070177010B0A01445A4700039E2053070100620AFFFF726201648FCC717577070100603201010172620162006200520004445A470177070100600100FF017262016200620052000B0A01445A4700039E20530177070100010800FF641C00047262016200621E52FF640200E80177070100020800FF017262016200621E52FF64E4D65D0177070100100700FF017262016200621B52FE53029C01010163E184007605D224AF016200620072630201710163280D00001B1B1B1B1A01E334"; 101 | 102 | SMLMeterData data=SMLDecoder.decode(Hex.decodeHex(payload), false); 103 | 104 | System.err.println(data); 105 | 106 | assertThat(data).isNotNull(); 107 | 108 | assertThat(data.getMeterId()).isEqualTo("1DZG0060694611"); 109 | 110 | assertThat(data.getReadings()) 111 | .isNotNull() 112 | .extracting(Object::toString) 113 | .containsExactlyInAnyOrder( 114 | "1-0:1.8.0*255 / energyImportTotal = 13130.4 WATT_HOUR", 115 | "1-0:2.8.0*255 / energyExportTotal = 1499708.5 WATT_HOUR", 116 | "1-0:16.7.0*255 / powerTotal = 6.77 WATT" 117 | ); 118 | } 119 | 120 | @Test 121 | public void testDZG_DVS_7420_2V_G2_mtr2_neg() throws Exception { 122 | // DZG_DVS-7420.2V.G2_mtr2_neg.hex 123 | // Remarks: Generator, has 2-bytes negative value 16.7.0 124 | 125 | String payload="1B1B1B1B0101010176051338B301620062007263010176010102310B0A01445A4700039E2053726201649128386202632F2B0076051438B301620062007263070177010B0A01445A4700039E2053070100620AFFFF726201649128387577070100603201010172620162006200520004445A470177070100600100FF017262016200620052000B0A01445A4700039E20530177070100010800FF641C69047262016200621E52FF640204E90177070100020800FF017262016200621E52FF64E4EE4D0177070100100700FF017262016200621B52FE53D6CA01010163A8900076051538B301620062007263020171016350C300001B1B1B1B1A010DE31B1B1B1B0101010176051638B301620062007263010176010102310B0A01445A4700039E205372620164912839620263F2320076051738B301620062007263070177010B0A01445A4700039E2053070100620AFFFF726201649128397577070100603201010172620162006200520004445A470177070100600100FF017262016200620052000B0A01445A4700039E20530177070100010800FF641C69047262016200621E52FF640204E90177070100020800FF017262016200621E52FF64E4EE4E0177070100100700FF017262016200621B52FE53D64A010101636A9B0076051838B301620062007263020171016360A100001B1B1B1B1A0156B71B1B1B1B0101010176051938B301620062007263010176010102310B0A01445A4700039E20537262016491283A62026395180076051A38B301620062007263070177010B0A01445A4700039E2053070100620AFFFF7262016491283A7577070100603201010172620162006200520004445A470177070100600100FF017262016200620052000B0A01445A4700039E20530177070100010800FF641C69047262016200621E52FF640204E90177070100020800FF017262016200621E52FF64E4EE4E0177070100100700FF017262016200621B52FE53D73A0101016371820076051B38B30162006200726302017101635E2200001B1B1B1B1A014BA01B1B1B1B0101010176051C38B301620062007263010176010102310B0A01445A4700039E20537262016491283B62026348010076051D38B301620062007263070177010B0A01445A4700039E2053070100620AFFFF7262016491283B7577070100603201010172620162006200520004445A470177070100600100FF017262016200620052000B0A01445A4700039E20530177070100010800FF641C69047262016200621E52FF640204E90177070100020800FF017262016200621E52FF64E4EE4E017707010010"; 126 | 127 | SMLMeterData data=SMLDecoder.decode(Hex.decodeHex(payload), false); 128 | 129 | System.err.println(data); 130 | 131 | assertThat(data).isNotNull(); 132 | 133 | assertThat(data.getMeterId()).isEqualTo("1DZG0060694611"); 134 | 135 | assertThat(data.getReadings()) 136 | .isNotNull() 137 | .extracting(Object::toString) 138 | .containsExactlyInAnyOrder( 139 | "1-0:1.8.0*255 / energyImportTotal = 13232.9 WATT_HOUR", 140 | "1-0:2.8.0*255 / energyExportTotal = 1500321.3 WATT_HOUR", 141 | "1-0:16.7.0*255 / powerTotal = -105.50 WATT" 142 | ); 143 | } 144 | 145 | @Test 146 | public void testDrNeuhaus_SMARTY_ix_130() throws Exception { 147 | // DrNeuhaus_SMARTY_ix-130.hex 148 | 149 | String payload="1B1B1B1B010101017605000004FE620062007263010176010105AA0100000B0901444E540100002030010163A802007605000004FF620062007263070177010B0901444E540100002030070100620AFFFF72620165715D52A97777078181C78203FF0101010104444E540177070100000009FF010101010B0901444E5401000020300177070100020800FF6401010001621E52FF59000000000000AAD20177070100020801FF0101621E52FF59000000000000AAD20177070100020802FF0101621E52FF59000000000000000001770701000F0700FF0101621B520065000000000177078181C78205FF010101018302FEA5D6BC658D734673F5790051D61C2EBD55344A6285686DE3B645EE0104D65F5ABBA6CB68F1BFCB7FCF94F99CD4D84501010163330A007605000005006200620072630201710163389800001B1B1B1B1A01C5741B1B1B1B01010101760500000501620062007263010176010105AB0100000B0901444E5401000020300101632D9F00760500000502620062007263070177010B0901444E540100002030070100620AFFFF72620165715D52AB7777078181C78203FF0101010104444E540177070100000009FF010101010B0901444E5401000020300177070100020800FF6401010001621E52FF59000000000000AAD20177070100020801FF0101621E52FF59000000000000AAD20177070100020802FF0101621E52FF59000000000000000001770701000F0700FF0101621B520065000000000177078181C78205FF010101018302FEA5D6BC658D734673F5790051D61C2EBD55344A6285686DE3B645EE0104D65F5ABBA6CB68F1BFCB7FCF94F99CD4D84501010163B6DE0076050000050362006200726302017101638B6600001B1B1B1B1A01DA891B1B1B1B01010101760500000504620062007263010176010105AC0100000B0901444E5401000020300101633F4C00760500000505620062007263070177010B0901444E540100002030070100620AFFFF72620165715D52AC7777078181C78203FF0101010104444E540177070100000009FF010101010B0901444E5401000020300177070100020800FF6401010001621E52FF59000000000000AAD20177070100020801FF0101621E52FF59000000000000AAD20177070100020802FF0101621E52FF59000000000000000001770701000F0700FF0101621B520065000000000177078181C78205FF010101018302FEA5D6BC658D734673F5790051D61C2EBD55344A6285686DE3B645EE0104D65F5ABBA6CB68F1BFCB7FCF94F99CD4D8450101016305380076050000050662006200726302017101634F6D00001B1B1B1B1A013DC41B1B1B1B01010101760500000507620062007263010176010105AD0100000B0901444E540100002030010163253D00760500000508620062007263070177010B0901444E540100002030070100620AFFFF72620165715D52AD7777078181C78203FF0101010104444E540177070100000009FF010101010B0901444E5401000020300177070100020800FF6401010001621E52FF59000000000000AAD20177070100020801FF0101621E52FF59000000000000AAD20177070100020802FF0101621E52FF59000000000000000001770701000F0700FF0101621B520065000000000177078181C78205FF010101018302FEA5D6BC658D734673F5790051D61C2EBD55344A6285686DE3B645EE0104D65F5ABBA6CB68F1BFCB7FCF94F99CD4D845010101631D15007605000005096200620072630201710163037100001B1B1B1B1A01F4941B1B1B1B0101010176050000050A620062007263010176010105AE0100000B0901444E54010000203001016382A10076050000050B620062007263070177010B0901444E540100002030070100620AFFFF72620165715D52AF7777078181C78203FF0101010104444E540177070100000009FF010101010B0901444E5401000020300177070100020800FF6401010001621E52FF59000000000000AAD20177070100020801FF0101621E52FF59000000000000AAD20177070100020802FF0101621E52FF59000000000000000001770701000F0700FF0101621B520065000000000177078181C78205FF010101018302FEA5D6BC658D734673F5790051D61C2EBD55344A6285686DE3B645EE0104D65F5ABBA6CB68F1BFCB7FCF94F99CD4D84501010163054D0076050000050C6200620072630201710163C77A00001B1B1B1B1A01C3171B1B1B1B0101010176050000050D620062007263010176010105AF0100000B0901444E54010000203001016354530076050000050E620062007263070177010B0901444E540100002030070100620AFFFF72620165715D52B07777078181C78203FF0101010104444E540177070100000009FF010101010B0901444E5401000020300177070100020800FF6401010001621E52FF59000000000000AAD20177070100020801FF0101621E52FF59000000000000AAD20177070100020802FF0101621E52FF59000000000000000001770701000F0700FF0101621B520065000000000177078181C78205FF010101018302FEA5D6BC658D734673F5790051D61C2EBD55344A6285686DE3B645EE0104D65F5ABBA6CB68F1BFCB7FCF94F99CD4D845010101638E320076050000050F6200620072630201710163748400001B1B1B1B1A01807F1B1B1B1B01010101760500000510620062007263010176010105B00100000B0901444E540100002030010163441800760500000511620062007263070177010B0901444E540100002030070100620AFFFF72620165715D52B17777078181C78203FF0101010104444E540177070100000009FF010101010B0901444E5401000020300177070100020800FF6401010001621E52FF59000000000000AAD20177070100020801FF0101621E52FF59000000000000AAD20177070100020802FF0101621E52FF59000000000000000001770701000F0700FF0101621B520065000000000177078181C78205FF010101018302FEA5D6BC658D734673F5790051D61C2EBD55344A6285686DE3B645EE0104D65F5ABBA6CB68F1BFCB7FCF94F99CD4D8450101016363890076050000051262006200726302017101635F4200001B1B1B1B1A01063F1B1B1B1B01010101760500000513620062007263010176010105B10100000B0901444E5401000020300101635E6900760500000514620062007263070177010B0901444E540100002030070100620AFFFF72620165715D52B37777078181C78203FF0101010104444E540177070100000009FF010101010B0901444E5401000020300177070100020800FF6401010001621E52FF59000000000000AAD20177070100020801FF0101621E52FF59000000000000AAD20177070100020802FF0101621E52FF59000000000000000001770701000F0700FF0101621B520065000000000177078181C78205FF010101018302FEA5D6BC658D734673F5790051D61C2EBD55344A6285686DE3B645EE0104D65F5ABBA6CB68F1BFCB7FCF94F99CD4D845010101638A3D007605000005156200620072630201710163B9E200001B1B1B1B1A0129BC1B1B1B1B01010101760500000516620062007263010176010105B20100000B0901444E54010000203001016370FA00760500000517620062007263070177010B0901444E540100002030070100620AFFFF72620165715D52B47777078181C78203FF0101010104444E540177070100000009FF010101010B0901444E5401000020300177070100020800FF6401010001621E52FF59000000000000AAD20177070100020801FF0101621E52FF59000000000000AAD20177070100020802FF0101621E52FF59000000000000000001770701000F0700FF0101621B520065000000000177078181C78205FF010101018302FEA5D6BC658D734673F5790051D61C2EBD55344A6285686DE3B645EE0104D65F5ABBA6CB68F1BFCB7FCF94F99CD4D84501010163676C007605000005186200620072630201710163D75500001B1B1B1B1A01E5CE1B1B1B1B01010101760500000519620062007263010176010105B30100000B0901444E5401000020300101632F070076050000051A620062007263070177010B0901444E540100002030070100620AFFFF72620165715D52B57777078181C78203FF0101010104444E540177070100000009FF010101010B0901444E5401000020300177070100020800FF6401010001621E52FF59000000000000AAD20177070100020801FF0101621E52FF59000000000000AAD20177070100020802FF0101621E52FF59000000000000000001770701000F0700FF0101621B520065000000000177078181C78205FF010101018302FEA5D6BC658D734673F5790051D61C2EBD55344A6285686DE3B645EE0104D65F5ABBA6CB68F1BFCB7FCF94F99CD4D845010101637F410076050000051B620062007263020171016364AB00001B1B1B1B1A017E821B1B1B1B0101010176050000051C620062007263010176010105B40100000B0901444E5401000020300101633DD40076050000051D620062007263070177010B0901444E540100002030070100620AFFFF72620165715D52B77777078181C78203FF0101010104444E540177070100000009FF010101010B0901444E5401000020300177070100020800FF6401010001621E52FF59000000000000AAD20177070100020801FF0101621E52FF59000000000000AAD20177070100020802FF0101621E52FF59000000000000000001770701000F0700FF0101621B520065000000000177078181C78205FF010101018302FEA5D6BC658D734673F5790051D61C2EBD55344A6285686DE3B645EE0104D65F5ABBA6CB68F1BFCB7FCF94F99CD4D8450101016339AE0076050000051E6200620072630201710163A0A000001B1B1B1B1A01FE291B1B1B1B0101010176050000051F620062007263010176010105B50100000B0901444E54010000203001016327A500760500000520620062007263070177010B0901444E540100002030070100620AFFFF72620165715D52B87777078181C78203FF0101010104444E540177070100000009FF010101010B0901444E5401000020300177070100020800FF6401010001621E52FF59000000000000AAD20177070100020801FF0101621E52FF59000000000000AAD20177070100020802FF0101621E52FF59000000000000000001770701000F0700FF0101621B520065000000000177078181C78205FF010101018302FEA5D6BC658D734673F5790051D61C2EBD55344A6285686DE3B645EE0104D65F5ABBA6CB68F1BFCB7FCF94F99CD4D84501010163F593007605000005216200620072630201710163232F00001B1B1B1B1A0168041B1B1B1B01010101760500000522620062007263010176010105B60100000B0901444E540100002030010163B61800760500000523620062007263070177010B0901444E540100002030070100620AFFFF72620165715D52B97777078181C78203FF0101010104444E540177070100000009FF010101010B0901444E5401000020300177070100020800FF6401010001621E52FF59000000000000AAD20177070100020801FF0101621E52FF59000000000000AAD20177070100020802FF0101621E52FF590000000000000000017707"; 150 | 151 | SMLMeterData data=SMLDecoder.decode(Hex.decodeHex(payload), false); 152 | 153 | System.err.println(data); 154 | 155 | assertThat(data).isNotNull(); 156 | 157 | assertThat(data.getMeterId()).isEqualTo("1DNT0100008240"); 158 | 159 | assertThat(data.getReadings()) 160 | .isNotNull() 161 | .extracting(Object::toString) 162 | .containsExactlyInAnyOrder( 163 | "1-0:2.8.0*255 / energyExportTotal = 4373.0 WATT_HOUR", 164 | "1-0:2.8.1*255 / energyExportTariff1 = 4373.0 WATT_HOUR", 165 | "1-0:2.8.2*255 / energyExportTariff2 = 0.0 WATT_HOUR", 166 | "1-0:15.7.0*255 = 0 WATT" 167 | ); 168 | } 169 | 170 | @Test 171 | @Ignore // workaround not yet in place 172 | public void testEMH_ED300L_consumption() throws Exception { 173 | // EMH-ED300L_consumption.hex 174 | 175 | 176 | String payload="1B01760004C96272017601000FED45480256450199071652EF00006301064D0171CD01010F767707818203010145487701000001010B45480256CD070008FF01011E0047F977010101011EFF0047F97707010201625256000077010F00016252000301078182FF01010230EFA3DC46052FB9CBA3959C5016B286B13C194298DBA0016322760016F20000630101F7001B1B01851B1B0101071652F46200630101071665ED45480256450144760004C96262726301064D0171CD01010F76770781820301044D0107000001010B45480256CD070008FF01011E560A200107000801011EFF0047FA7707010201625256000077010F0001625200AB7781C7050183E85118880B0527CB80B2FD7A1674D0DB57BE38429806010161000716F800006301017F001B1B01851B0101760004C96200630101071665ED064D0171CD0163EE071652FB000063770B4548025645016565A07781C70301014548770100000101064D0171CD770101006382621E0047FB77010101011EFF000A200107010201625256000077010F0001625200C67781C705010101018302E830EFA3DC46052FB9CBA3959C5016B286B13C194298060101D7000716FE000063010108001B1B01F81B1B0101071652006272017601000FEE4548025645012B760004CA62627263770B454802564501010F767707818203FF0101010104454D480177070100000009FF010101010B06454D4801027156CD070008FF01011E0047FC770101016252560A20010708FF011EFF000001070007FF011B52000301078182FF0183E85118880B052FB9CBA3959C501674D0DB57BE384298DBA0016301760016CA626272027163BF1B1B1AC61B0101071652066272017601000FEE454802564501A2760004CA6262726301064D0171CD016565A77781C703010145487701000001010B45480256CD070008FF01011E0047FD770101016252560A20010708FF015256000077010F000162527781C7050183E85118880B0527CB80B2FD7A1662001B1B014C1B1B01010716520C6272017601000FEE454802564501280716520D00006301064D56010F76770781820301454877010000010106480256CD070008FF01011E0047FE770101016252FF0047FE770708FF011EFF000077010F0001625200C87781C7050183E85118880B0527CB80B2FD7A16B286B13C19429806010181000716100000630101AF1B1B1A9F1B1B01010716521262720101071665EE454802564501D50704CA6262726301064D0171CD010F76770781820301044D010700000101064D0171CD070008FF01011E016252560A20010708FF011EFF0000070007FF011B5200BD7781C7050183E85118880B0527CB80B2FD7A16B286B13C194298060101F9000716160000630101D81B1B1AA41B1B0101760004CA6200630101071665EE064D0171CD01633C0716521900006301064802564501010F76770781820301044D010700000101064D0171CD070008FF01011E004700770101016252560A210107010201625256000001070007FF011B52000301078182FF0183E851A3DC460527CB80B2FD7A16B286B13C1942980601014D7600161C000002716350001B1B01EF1B0101760004CA62630101071665EE454802564501B50716521F00006301064D0171CD010F76770781820301044D010700000101064D0171CD070008FF01011E004701770101016252560A21010708FF011EFF000077010F0001625200797781C7050183E85118880B0527CB80B2FD7A16B286B13C194298A0016339760016220000630101421B1B1A861B0101760004CA6200630101071665EE4D0171564501C60004CA62627263770B45480256450181820301044D010700000101064D0171CD070008FF01011E004702770101016252560A21010708FF011EFF000077010F0001625200A77781C7FF0183E85118880B05B9CBA3959C5016B28657BE384298060101EF000716280000630101CA1B1B1A271B1B0101760004CA6272017601000FEE4548025645016C0716526262726301064D0171CD016565BE7781C70301044D010700000101064D0171CD450177070100010800FF63018201621E004703770101016252560A210107010201625256000001070007FF011B5200A87781C7050183E85118880B0527CB80B2FD7A1674D0DB57BE384298DBA00163A37600162E0000630101BD001B1B01271B1B01010716526272017601000FEE454802564501050716523100006301064D0171CD016565C27781C70301044D010700000101064D0171CD070008FF01011E004704770101016252560A210107010201625256000077010F0001625200B67781C705010101018302E83051EF18A388DC0B4605B9CBA3959C5016B286B13C194298060101A0000716CA626272027163701B1B1A1D1B1B0101071652366272017601000FEE4548025645018C760004CA6262726301064D0171CD016565C67781C70301044D010700000101064D0171CD070008FF01011E004705770101016252560A21010708FF011EFF000077010F0001625200E67781C7050183E85118880B0527CB80B2FD7A1674D0DB57BE384298DBA00163917600163A0000630101AD1B1B1A631B01010716523C6272017601000FEE45480256450106760004CA6262726301064D0171CD01010F76770781820301044D010700000101064D0171CD770101006382621E004706770101016252560A21770708FF011EFF000077010F0001625200C77781C7FF0183E85118880B0527CB80B2FD7A16B286B13C194298060101C3000716400000630101EF1B1B1A7E1B0101071652426272017601000FEE454802564501180716524300006301064D0171CD016565CD7781C70301044D0107000001010B45480256CD0701006382621E560A2101070008016252560A21010708FF011EFF000077010F0001625200C87781C7050183E85118880B052FCB80B2FD7A1674D0DB57BE384298060163AF760016460000630101981B1B1AE21B01017600046272017601000FEE4D0171CD0163F10716524900006301064D0171CD016565D07781C70301044D0107000001010B45480256CD070008FF01011E52FF56000A4721080177070100010801011EFF004701070201625256000077010F00016252000501078182FF01010230EFA3DC460527CB80B2FD7A16B2DB57BE384298060101FF000716CA626272027163101B1B1A6E1B0101760004CA6272017601000FEE454802564501780716524F00006301064D0171CD016565D17781C70301044D010700000101064D0171CD070008FF01011E00210107000801011EFF00470B770708FF011EFF000077010F000162520001078182FF0183E85118880B052FB9CBA3959C5016B286B13C19429806010121000716520000630101881B1B01F61B1B0101760004CA6272017601000FEE454802564501A50716525500006301064D0171CD016565D47781C70301044D01070000010B45480256CD070008FF01011E002101070008016252560A21010708FF011EFF000077010F000162520001078182FF01010230EFA3DC460527CB80B2FD7A16B286B13C19429806010166000716580000630101001B1B1A0C1B1B01760004CA62720101071665EE4548025645010F0004CA6262726301064D0171CD016565D777818203014548770100000101064D0171CD070008FF01011E00470E770101016252560A21010702016252560000000177010F07FF01520501078182FF0183E85118880B0527CB80B2FD7A16B286B13C1942980601014B0007165E0000630101771B1B1A211B1B0101071652606200630101000FEE4D0171CD0163770004CA6262726301064D0171CD016565D97781C70301044D01070000010B45480256CD070008FF01011E00470107000801625200470F770708FF011EFF000077010F00016252002A7781C7050183E85118880B05B9CBA3959C501674D0DB57BE38429806010128000716640000630101301B1B1A291B0101760004CA6272017601000FEE454802564501FE0716526700006301064D0171CD016565DC7781C703014548770100000101064D0171CD070008FF01011E004710770101016252560A21010708FF015256000077010F00016252006C7781C7050183E85118880B0527CB80B2FD7A16B286B13C194298060101A00007166A0000630101ED1B1B1AF81B0101760004CA62720101071665EE454802564501740004CA6262726301064D0171CD01650703014548770100000101064D0171CD070008FF01011E004711770101016252564711770708FF011EFF000077010F0001625200497781C7050183E85118880B0527CB80B2FD7A16B286B13C19429806010147000716700000630101201B1B1A2D1B0101071652726272017601000FEE454802564501890716527300006301064D0171CD016565E17781C70301044D01070000010B45480256CD070008FF01011E00471277010101FF0101621E52FF56000A4721120177070102016252560077010F00016252000501078182FF0183E85118880B0527CB80B2FD7A16B286B13C194298060101F400071676000063010157001B1B1A221B1B1B1B01010101760700160452CA7862006200726301017601010700160F65EE280B06454D4801027156CD4501016360F500760700160452CA79620062007263070177010B06454D4801027156CD4501726201650F6576E37777078181C78203FF0101010104454D480177070100000009FF010101010B06454D4801027156CD450177070100010800FF63018201621E52FF56000A4721130177070100010801FF0101621E52FF56000A4721130177070100010802FF0101621E52FF56000000000001770701000F0700FF0101621B52FF550000055B0177078181C78205FF010101018302E83051EF18A388DC0B4605DBFF2F27B9CBCB80A3B295FD9C7A5016D42774B2D086DBB1573CBE193842429840EDDB06A0010101634C0000760700160452CA7C6200620072630201710163DF8800001B1B1B1B1A01B01F1B1B1B1B01010101760700160452CA7E62006200726301017601010700160F65EE2A0B06454D4801027156CD45010163E93300760700160452CA7F620062007263070177010B06454D4801027156CD4501726201650F6576E67777078181C78203FF0101010104454D480177070100000009FF010101010B06454D4801027156CD450177070100010800FF63018201621E52FF56000A4721140177070100010801FF016252560A210107010201625256000001070007FF011B525500AB7781C70501010230EFA3DC46052FB9CBA3959C501674D0DB57BE38980601014C000716820000630101D31B1B1AC51B0101760004CA6272017601000FEE064D0171CD01634D0716528500006301064D0171CD016565E87781C70301014548770100000101064D0171CD070008FF01011E560A2101070008016252560A21010708FF62525600000077010007FF016252000501078182FF01010230EFA3DC46052FB9CBA3959C5016B286B13C194298060101A90007168800006301015B1A001B1B01731B1B0101760004CA6200630101071665EE064D0171CD0163E7000716528B000063770B4548025645016565EB7781C70301014548770100000101064D0171CD0700006382621E560A2101070008016252560A21010708FF011EFF000001070007FF011B52000501078182FF0183E85118880B052F"; 177 | 178 | SMLMeterData data=SMLDecoder.decode(Hex.decodeHex(payload), false); 179 | 180 | System.err.println(data); 181 | 182 | assertThat(data).isNotNull(); 183 | 184 | assertThat(data.getMeterId()).isEqualTo("1DNT0100008240"); 185 | 186 | assertThat(data.getReadings()) 187 | .isNotNull() 188 | .extracting(Object::toString) 189 | .containsExactlyInAnyOrder( 190 | "1-0:2.8.0*255 / energyExportTotal = 4373.0 WATT_HOUR", 191 | "1-0:2.8.1*255 / energyExportTariff1 = 4373.0 WATT_HOUR", 192 | "1-0:2.8.2*255 / energyExportTariff2 = 0.0 WATT_HOUR", 193 | "1-0:15.7.0*255 = 0 WATT" 194 | ); 195 | } 196 | 197 | 198 | } 199 | -------------------------------------------------------------------------------- /src/test/java/de/wyraz/tibberpulse/sml/SMLDecoderTests.java: -------------------------------------------------------------------------------- 1 | package de.wyraz.tibberpulse.sml; 2 | 3 | import static org.assertj.core.api.Assertions.assertThat; 4 | import static org.assertj.core.api.Assertions.assertThatCode; 5 | 6 | import java.io.IOException; 7 | 8 | import org.apache.commons.codec.binary.Hex; 9 | import org.junit.Test; 10 | 11 | import de.wyraz.sml.asn1.ASN1BERTokenizer; 12 | 13 | public class SMLDecoderTests { 14 | 15 | static { 16 | SMLDecoder.DUMP_RAW_SML=true; 17 | } 18 | 19 | @Test 20 | public void testSMLDecoderInvalidStartSequence() throws Exception { 21 | String payload="1c1b1b1b010101017605128e14cf620062007265000001017601010765425a4444330b090145425a0100091f14010163df26007605128e14d06200620072650000070177010b090145425a0100091f14017262016505e465007a77078181c78203ff010101010445425a0177070100000009ff010101010b090145425a0100091f140177070100010800ff6401018001621e52fb69000002a05939af070177070100010801ff0101621e52fb69000002a0531f2f070177070100010802ff0101621e52fb6900000000061a80000177070100020800ff6401018001621e52fb69000000000d7b8b460177070100100700ff0101621b52fe5500013c060177070100240700ff0101621b52fe55000058f00177070100380700ff0101621b52fe55000085f901770701004c0700ff0101621b52fe5500005d1d010101636c93007605128e14d162006200726500000201710163c682000000001b1b1b1b1a032f09"; 22 | 23 | assertThatCode(()->{ 24 | SMLDecoder.decode(Hex.decodeHex(payload)); 25 | }) 26 | .isInstanceOf(IOException.class) 27 | .hasMessageStartingWith("Invalid SML payload: 1c1b1b1"); 28 | } 29 | @Test 30 | public void testSMLDecoderInvalidEndSequence() throws Exception { 31 | String payload="1b1b1b1b010101017605128e14cf620062007265000001017601010765425a4444330b090145425a0100091f14010163df26007605128e14d06200620072650000070177010b090145425a0100091f14017262016505e465007a77078181c78203ff010101010445425a0177070100000009ff010101010b090145425a0100091f140177070100010800ff6401018001621e52fb69000002a05939af070177070100010801ff0101621e52fb69000002a0531f2f070177070100010802ff0101621e52fb6900000000061a80000177070100020800ff6401018001621e52fb69000000000d7b8b460177070100100700ff0101621b52fe5500013c060177070100240700ff0101621b52fe55000058f00177070100380700ff0101621b52fe55000085f901770701004c0700ff0101621b52fe5500005d1d010101636c93007605128e14d162006200726500000201710163c682000000001c1b1b1b1a032f09"; 32 | 33 | assertThatCode(()->{ 34 | SMLDecoder.decode(Hex.decodeHex(payload)); 35 | }) 36 | .isInstanceOf(IOException.class) 37 | .hasMessageStartingWith("Invalid SML payload: 1b1b1b1"); 38 | } 39 | @Test 40 | public void testSMLDecoderInvalidCRC() throws Exception { 41 | String payload="1b1b1b1b010101017605128e14cf620062007265000001017601010765425a4444330b090145425a0100091f14010163df26007605128e14d06200620072650000070177010b090145425a0100091f14017262016505e465007a77078181c78203ff010101010445425a0177070100000009ff010101010b090145425a0100091f140177070100010800ff6401018001621e52fb69000002a05939af070177070100010801ff0101621e52fb69000002a0531f2f070177070100010802ff0101621e52fb6900000000061a80000177070100020800ff6401018001621e52fb69000000000d7b8b460177070100100700ff0101621b52fe5500013c060177070100240700ff0101621b52fe55000058f00177070100380700ff0101621b52fe55000085f901770701004c0700ff0101621b52fe5500005d1d010101636c93007605128e14d162006200726500000201710163c682000000001b1b1b1b1a032f08"; 42 | 43 | assertThatCode(()->{ 44 | SMLDecoder.decode(Hex.decodeHex(payload)); 45 | }) 46 | .isInstanceOf(IOException.class) 47 | .hasMessage("Invalid SML payload: wrong crc"); 48 | } 49 | 50 | /** 51 | * Initial test with data from my own meter 52 | */ 53 | @Test 54 | public void testSMLDecoderEBZ() throws Exception { 55 | String payload="1b1b1b1b010101017605128e14cf620062007265000001017601010765425a4444330b090145425a0100091f14010163df26007605128e14d06200620072650000070177010b090145425a0100091f14017262016505e465007a77078181c78203ff010101010445425a0177070100000009ff010101010b090145425a0100091f140177070100010800ff6401018001621e52fb69000002a05939af070177070100010801ff0101621e52fb69000002a0531f2f070177070100010802ff0101621e52fb6900000000061a80000177070100020800ff6401018001621e52fb69000000000d7b8b460177070100100700ff0101621b52fe5500013c060177070100240700ff0101621b52fe55000058f00177070100380700ff0101621b52fe55000085f901770701004c0700ff0101621b52fe5500005d1d010101636c93007605128e14d162006200726500000201710163c682000000001b1b1b1b1a032f09"; 56 | 57 | SMLMeterData data=SMLDecoder.decode(Hex.decodeHex(payload)); 58 | 59 | assertThat(data).isNotNull(); 60 | 61 | assertThat(data.getMeterId()).isEqualTo("1EBZ0100597780"); 62 | 63 | assertThat(data.getReadings()) 64 | .isNotNull() 65 | .extracting(Object::toString) 66 | .containsExactlyInAnyOrder( 67 | "1-0:1.8.0*255 / energyImportTotal = 28877149.75495 WATT_HOUR", 68 | "1-0:1.8.1*255 / energyImportTariff1 = 28876125.75495 WATT_HOUR", 69 | "1-0:1.8.2*255 / energyImportTariff2 = 1024.00000 WATT_HOUR", 70 | "1-0:2.8.0*255 / energyExportTotal = 2262.00390 WATT_HOUR", 71 | "1-0:16.7.0*255 / powerTotal = 809.02 WATT", 72 | "1-0:36.7.0*255 / powerL1 = 227.68 WATT", 73 | "1-0:56.7.0*255 / powerL2 = 342.97 WATT", 74 | "1-0:76.7.0*255 / powerL3 = 238.37 WATT" 75 | ); 76 | 77 | } 78 | 79 | @Test 80 | public void testSMLDecoderEFR_short() throws Exception { 81 | String payload="1b1b1b1b010101017605080e16b66200620072630101760107ffffffffffff0502af5ce80b0a014546522102cd630c7262016502af5ce5016333fe007605080e16b762006200726307017707ffffffffffff0b0a014546522102cd630c070100620affff7262016502af5ce579770701006032010101010101044546520177070100600100ff010101010b0a014546522102cd630c0177070100010800ff641c40047262016502af5ce5621e52ff65016d58b80177070100020800ff017262016502af5ce5621e52ff650286c57c017707010000020000010101010630332e30300177070100605a0201010101010342bd01770701006161000001010101030000017707010060320104010101010850312e322e31320177070100603204040101010103042201010163c14a007605080e16b862006200726302017101639568000000001b1b1b1b1a0309d3"; 82 | 83 | SMLMeterData data=SMLDecoder.decode(Hex.decodeHex(payload)); 84 | 85 | System.err.println(data); 86 | 87 | assertThat(data).isNotNull(); 88 | 89 | assertThat(data.getMeterId()).isEqualTo("1EFR3347014668"); 90 | 91 | assertThat(data.getReadings()) 92 | .isNotNull() 93 | .extracting(Object::toString) 94 | .containsExactlyInAnyOrder( 95 | "1-0:1.8.0*255 / energyImportTotal = 2394335.2 WATT_HOUR", 96 | "1-0:2.8.0*255 / energyExportTotal = 4238681.2 WATT_HOUR" 97 | ); 98 | 99 | } 100 | 101 | @Test 102 | public void testSMLDecoderEFR_long() throws Exception { 103 | String payload="1b1b1b1b010101017605080e4fb36200620072630101760107ffffffffffff0502af6fe70b0a014546522102cd630c7262016502af6fe40163ca28007605080e4fb462006200726307017707ffffffffffff0b0a014546522102cd630c070100620affff7262016502af6fe4f106770701006032010101010101044546520177070100600100ff010101010b0a014546522102cd630c0177070100010800ff641c58047262016502af6fe4621e52ff65016d59500177070100020800ff017262016502af6fe4621e52ff650286c63b0177070100100700ff0101621b520052f70177070100200700ff0101622352ff6309310177070100340700ff0101622352ff63093a0177070100480700ff0101622352ff63093701770701001f0700ff0101622152fe62680177070100330700ff0101622152fe62b80177070100470700ff0101622152fe62740177070100510701ff01016208520052790177070100510702ff0101620852005300ee0177070100510704ff010162085200530108017707010051070fff010162085200530120017707010051071aff0101620852005300e301770701000e0700ff0101622c52ff6301f4017707010000020000010101010630332e30300177070100605a0201010101010342bd01770701006161000001010101030000017707010060320104010101010850312e322e313201770701006032040401010101030422010101635256007605080e4fb56200620072630201710163fa1200001b1b1b1b1a01fd46"; 104 | 105 | SMLMeterData data=SMLDecoder.decode(Hex.decodeHex(payload)); 106 | 107 | System.err.println(data); 108 | 109 | assertThat(data).isNotNull(); 110 | 111 | assertThat(data.getMeterId()).isEqualTo("1EFR3347014668"); 112 | 113 | assertThat(data.getReadings()) 114 | .isNotNull() 115 | .extracting(Object::toString) 116 | .containsExactlyInAnyOrder( 117 | "1-0:1.8.0*255 / energyImportTotal = 2394350.4 WATT_HOUR", 118 | "1-0:2.8.0*255 / energyExportTotal = 4238700.3 WATT_HOUR", 119 | "1-0:16.7.0*255 / powerTotal = -9 WATT", 120 | "1-0:32.7.0*255 / voltageL1 = 235.3 VOLT", 121 | "1-0:52.7.0*255 / voltageL2 = 236.2 VOLT", 122 | "1-0:72.7.0*255 / voltageL3 = 235.9 VOLT", 123 | "1-0:31.7.0*255 / currentL1 = 1.04 AMPERE", 124 | "1-0:51.7.0*255 / currentL2 = 1.84 AMPERE", 125 | "1-0:71.7.0*255 / currentL3 = 1.16 AMPERE", 126 | "1-0:81.7.1*255 / phaseAngleUL2toUL1 = 121 DEGREE", 127 | "1-0:81.7.2*255 / phaseAngleUL3toUL1 = 238 DEGREE", 128 | "1-0:81.7.4*255 / phaseAngleIL1toUL1 = 264 DEGREE", 129 | "1-0:81.7.15*255 / phaseAngleIL2toUL2 = 288 DEGREE", 130 | "1-0:81.7.26*255 / phaseAngleIL3toUL3 = 227 DEGREE", 131 | "1-0:14.7.0*255 / networkFrequency = 50.0 HERTZ" 132 | ); 133 | 134 | } 135 | 136 | @Test 137 | public void testSMLDecoderHYL_invalidMessage() throws Exception { 138 | // invalid SML structure (but "well known spec violation") - add a workaround 139 | String payload="1b1b1b1b0101010176040000016200620072650000010176010107000005e370330b0a01484c59020005dc4b010163d7210076040000026200620072650000070177010b0a01484c59020005dc4b01017c77070100603201010101010104484c590177070100600100ff010101010b0a01484c59020005dc4b0177070100010800ff65000000046505e37033621e52ff6504c2bfcc0177070100020800ff65000000046505e37033621e52ff65002d3d840177070100100700ff0101621b520052000177070100200700ff0101622352ff63090301770701001f0700ff0101622152fe62000177070100510704ff010162085200620001770701000e0700ff0101622c52ff6301f30177070100000200000101010109322e30312e3030310177070100605a02010101010105303246340177070100600500ff01010101650000000401010163682f00760400000362006200726500000201710163e8230000001b1b1b1b1a021af2"; 140 | 141 | SMLMeterData data=SMLDecoder.decode(Hex.decodeHex(payload), false); 142 | 143 | System.err.println(data); 144 | 145 | assertThat(data).isNotNull(); 146 | 147 | assertThat(data.getMeterId()).isEqualTo("1HLY0200384075"); 148 | 149 | assertThat(data.getReadings()) 150 | .isNotNull() 151 | .extracting(Object::toString) 152 | .containsExactlyInAnyOrder( 153 | "1-0:1.8.0*255 / energyImportTotal = 7987194.8 WATT_HOUR", 154 | "1-0:2.8.0*255 / energyExportTotal = 296486.8 WATT_HOUR", 155 | "1-0:16.7.0*255 / powerTotal = 0 WATT", 156 | "1-0:32.7.0*255 / voltageL1 = 230.7 VOLT", 157 | "1-0:31.7.0*255 / currentL1 = 0.00 AMPERE", 158 | "1-0:81.7.4*255 / phaseAngleIL1toUL1 = 0 DEGREE", 159 | "1-0:14.7.0*255 / networkFrequency = 49.9 HERTZ" 160 | ); 161 | 162 | } 163 | 164 | @Test 165 | public void testSMLDecoderEMH_ED300L() throws Exception { 166 | // a meter reported by a user as not decoded properly 167 | 168 | String payload="1b1b1b1b010101017607000c05de60f1620062007263010176010107000c0282cafb0b0901454d480000abd62a010163ea5f007607000c05de60f2620062007263070177010b0901454d480000abd62a070100620affff72620165028215f37e77078181c78203ff0101010104454d480177070100000000ff010101010f31454d48303031313236313438320177070100000009ff010101010b0901454d480000abd62a0177070100010800ff640102a001621e52ff5600034dd71f0177070100020800ff640102a001621e52ff560000116bbf0177070100010801ff0101621e52ff5600000000000177070100020801ff0101621e52ff560000116bbf0177070100010802ff0101621e52ff5600034dd71f0177070100020802ff0101621e52ff5600000000000177070100100700ff0101621b52ff55fffffdfe0177070100240700ff0101621b52ff55000000000177070100380700ff0101621b52ff55ffffed5301770701004c0700ff0101621b52ff55000010ab0177078181c78205ff010101018302c589dde68439c20d18cee93dbe78f31c5631ebd3c08854aca19472b39f1c2dd4179d445b05dfd68c3de8ca1a05bdc92c01010163d727007607000c05de60f562006200726302017101638e9e001b1b1b1b1a004c66"; 169 | 170 | SMLMeterData data=SMLDecoder.decode(Hex.decodeHex(payload), false); 171 | 172 | System.err.println(data); 173 | 174 | assertThat(data).isNotNull(); 175 | 176 | assertThat(data.getMeterId()).isEqualTo("1EMH0011261482"); 177 | 178 | assertThat(data.getReadings()) 179 | .isNotNull() 180 | .extracting(Object::toString) 181 | .containsExactlyInAnyOrder( 182 | "1-0:1.8.0*255 / energyImportTotal = 5543299.1 WATT_HOUR", 183 | "1-0:2.8.0*255 / energyExportTotal = 114169.5 WATT_HOUR", 184 | "1-0:1.8.1*255 / energyImportTariff1 = 0.0 WATT_HOUR", 185 | "1-0:2.8.1*255 / energyExportTariff1 = 114169.5 WATT_HOUR", 186 | "1-0:1.8.2*255 / energyImportTariff2 = 5543299.1 WATT_HOUR", 187 | "1-0:2.8.2*255 / energyExportTariff2 = 0.0 WATT_HOUR", 188 | "1-0:16.7.0*255 / powerTotal = -51.4 WATT", 189 | "1-0:36.7.0*255 / powerL1 = 0.0 WATT", 190 | "1-0:56.7.0*255 / powerL2 = -478.1 WATT", 191 | "1-0:76.7.0*255 / powerL3 = 426.7 WATT" 192 | ); 193 | } 194 | 195 | @Test 196 | public void testSMLDecoder_eHZ_PW8E2A6L0HQ2D() throws Exception { 197 | // Test for issue reported in https://github.com/micw/tibber-pulse-reader/issues/15 198 | 199 | String payload="1b1b1b1b0101010176050013f6ff6200620072630101760107ffffffffffff050006a7ab0b0a01454d480000c312a17262016406aea2620163b7fa0076050013f70062006200726307017707ffffffffffff0b0a01454d480000c312a1070100620affff7262016406aea27577070100603201010101010104454d480177070100600100ff010101010b0a01454d480000c312a10177070100010800ff640801047262016406aea2621e52ff6403e05a0177070100020800ff017262016406aea2621e52ff6367540177070100100700ff0101621b520052240101016305eb0076050013f7016200620072630201710163fd5c001b1b1b1b1a000d95"; 200 | 201 | SMLMeterData data=SMLDecoder.decode(Hex.decodeHex(payload), false); 202 | 203 | System.err.println(data); 204 | 205 | assertThat(data).isNotNull(); 206 | 207 | assertThat(data.getMeterId()).isEqualTo("1EMH0012784289"); 208 | 209 | assertThat(data.getReadings()) 210 | .isNotNull() 211 | .extracting(Object::toString) 212 | .containsExactlyInAnyOrder( 213 | "1-0:1.8.0*255 / energyImportTotal = 25404.2 WATT_HOUR", 214 | "1-0:2.8.0*255 / energyExportTotal = 2645.2 WATT_HOUR", 215 | "1-0:16.7.0*255 / powerTotal = 36 WATT" 216 | ); 217 | } 218 | 219 | 220 | 221 | } 222 | --------------------------------------------------------------------------------