diff --git a/.dockerignore b/.dockerignore index bba0681..633f2e1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,10 +1,15 @@ +.dockerignore +.env +.env.* .git .github .gitignore -.env.* -.env +.rspec +.rubocop.yml +.vscode +coverage docker-compose.yml -test -Rakefile Dockerfile -coverage +Guardfile +Rakefile +spec diff --git a/.env.example b/.env.example index 8a1e1a3..ddfd824 100644 --- a/.env.example +++ b/.env.example @@ -1,37 +1,158 @@ -##### MQTT Broker credentials - +##### MQTT Broker credentials MQTT_HOST=my-mqtt-broker.local MQTT_PORT=1883 MQTT_SSL=false -MQTT_USERNAME=my-username -MQTT_PASSWORD=my-password +MQTT_USERNAME=my-mqtt-username +MQTT_PASSWORD=my-mqtt-password -##### MQTT Topics +##### Mappings -# Example 1: For ioBroker with SENEC adapter -# -MQTT_TOPIC_HOUSE_POW=senec/0/ENERGY/GUI_HOUSE_POW -MQTT_TOPIC_GRID_POW=senec/0/ENERGY/GUI_GRID_POW -MQTT_TOPIC_BAT_CHARGE_CURRENT=senec/0/ENERGY/GUI_BAT_DATA_CURRENT -MQTT_TOPIC_BAT_FUEL_CHARGE=senec/0/ENERGY/GUI_BAT_DATA_FUEL_CHARGE -MQTT_TOPIC_BAT_POWER=senec/0/ENERGY/GUI_BAT_DATA_POWER -MQTT_TOPIC_BAT_VOLTAGE=senec/0/ENERGY/GUI_BAT_DATA_VOLTAGE -MQTT_TOPIC_CASE_TEMP=senec/0/TEMPMEASURE/CASE_TEMP -MQTT_TOPIC_CURRENT_STATE=senec/0/ENERGY/STAT_STATE_Text -MQTT_TOPIC_MPP1_POWER=senec/0/PV1/MPP_POWER/0 -MQTT_TOPIC_MPP2_POWER=senec/0/PV1/MPP_POWER/1 -MQTT_TOPIC_MPP3_POWER=senec/0/PV1/MPP_POWER/2 -MQTT_TOPIC_INVERTER_POWER=senec/0/ENERGY/GUI_INVERTER_POWER -MQTT_TOPIC_WALLBOX_CHARGE_POWER=senec/0/WALLBOX/APPARENT_CHARGING_POWER/0 +# You can define multiple mappings. Each mapping is defined by multiple environment variables. +# Each variables starts with the MAPPING_X_ prefix, where X is a number starting from 0. +# +# A mapping defines how to get data from an MQTT message and where to write it to in InfluxDB. +# At least if requires the topic name, the measurement name, the field name and the data type. +# It allows to handle positive and negative values differently, and allows to handle JSON payloads. +# +# Allowed environment variables for each mapping are: +# +# - MAPPING_X_TOPIC: the MQTT topic to subscribe to +# - MAPPING_X_MEASUREMENT: the name of the InfluxDB measurement to write to +# - MAPPING_X_MEASUREMENT_POSITIVE: the name of the InfluxDB measurement to write to for positive values (optional) +# - MAPPING_X_MEASUREMENT_NEGATIVE: the name of the InfluxDB measurement to write to for negative values (optional) +# - MAPPING_X_FIELD: the name of the InfluxDB field to write to +# - MAPPING_X_FIELD_POSITIVE: the name of the InfluxDB field to write to for positive values (optional) +# - MAPPING_X_FIELD_NEGATIVE: the name of the InfluxDB field to write to for negative values (optional) +# - MAPPING_X_TYPE: the data type of the field. It can be one of integer, float, string or boolean +# - MAPPING_X_JSON_KEY: the key in the JSON payload to extract the value from (optional, only for JSON payloads) +# - MAPPING_X_JSON_PATH: the JSONPath in the JSON payload to extract the value from (optional, only for JSON payloads) +# - MAPPING_X_JSON_FORMULA: a formula to calculate the value from the JSON payload (optional, only for JSON payloads) +# +# +# Here are some examples of mappings: +# +# Basic mapping: +# MAPPING_0_TOPIC=senec/0/ENERGY/GUI_INVERTER_POWER +# MAPPING_0_MEASUREMENT=PV +# MAPPING_0_FIELD=inverter_power +# MAPPING_0_TYPE=float +# +# Mapping with positive and negative values: +# MAPPING_1_TOPIC=senec/0/ENERGY/GUI_GRID_POW +# MAPPING_1_MEASUREMENT_POSITIVE=PV +# MAPPING_1_MEASUREMENT_NEGATIVE=PV +# MAPPING_1_FIELD_POSITIVE=grid_import_power +# MAPPING_1_FIELD_NEGATIVE=grid_export_power +# MAPPING_1_TYPE=float +# +# Mapping with simple JSON payload (using JSON_KEY): +# MAPPING_2_TOPIC=my/little/nuclear/plant +# MAPPING_2_JSON_KEY=radiation_level +# MAPPING_2_MEASUREMENT=nuclear_power_plant +# MAPPING_2_FIELD=radiation_level +# MAPPING_2_TYPE=float +# +# This extracts a value from a payload like `{"radiation_level": 90.5, "reactivity": 0.7}`. In this example, it returns the value of the key `radiation_level`, which is 90.5. +# +# Mapping with complex JSON payload (using JSON_PATH): +# MAPPING_3_TOPIC=go-e/ATTR +# MAPPING_3_JSON_PATH=$.ccp[2] +# MAPPING_3_MEASUREMENT=WALLBOX +# MAPPING_3_FIELD=power +# MAPPING_3_TYPE=float +# +# This extracts value from a payload like `{"ccp": [1,2,42,3]}`. In this example, it returns the value at the index 2 (third element) of the array `ccp`, which is 42. +# +# For JSONPath specification (the `$.` notation) see here: +# https://goessner.net/articles/JsonPath/ +# +# Mapping with JSON payload and formula: +# MAPPING_4_TOPIC=my/little/nuclear/plant +# MAPPING_4_JSON_KEY=radiation_level +# MAPPING_4_MEASUREMENT=nuclear_power_plant +# MAPPING_4_FIELD=radiation_level +# MAPPING_4_TYPE=float +# +# MAPPING_5_TOPIC=my/little/nuclear/plant +# MAPPING_5_JSON_KEY=reactivity +# MAPPING_5_MEASUREMENT=nuclear_power_plant +# MAPPING_5_FIELD=reactivity +# MAPPING_5_TYPE=float +# +# MAPPING_6_TOPIC=my/little/nuclear/plant +# MAPPING_6_JSON_FORMULA="round({reactivity} * {radiation_level}) + 42" +# MAPPING_6_MEASUREMENT=nuclear_power_plant +# MAPPING_6_FIELD=danger_level +# MAPPING_6_TYPE=float +# +# For a payload like `{"radiation_level": 90.5, "reactivity": 0.7}` it calculates a `danger_level` as `round(0.7 * 90.5) + 42`, which is 105. +# +# Please note: +# - The curly braces are used to reference specific values in the JSON payload. You can use a simple key or a JSONPath +# to reference the value to use +# - The same topic is used for multiple mappings, but different json_keys (or json_formula) are used. +# +# For operators allowed in formulas see here: +# https://github.com/rubysolo/dentaku?tab=readme-ov-file#built-in-operators-and-functions -# Example 2: For evcc -# MQTT_TOPIC_HOUSE_POW=evcc/site/homePower -# MQTT_TOPIC_GRID_POW=evcc/site/gridPower -# MQTT_TOPIC_BAT_FUEL_CHARGE=evcc/site/batterySoc -# MQTT_TOPIC_BAT_POWER=evcc/site/batteryPower -# MQTT_TOPIC_INVERTER_POWER=evcc/site/pvPower -# MQTT_TOPIC_WALLBOX_CHARGE_POWER=evcc/loadpoints/1/chargePower -# MQTT_FLIP_BAT_POWER=true +MAPPING_0_TOPIC=senec/0/ENERGY/GUI_INVERTER_POWER +MAPPING_0_MEASUREMENT=PV +MAPPING_0_FIELD=inverter_power +MAPPING_0_TYPE=integer +# +MAPPING_1_TOPIC=senec/0/ENERGY/GUI_HOUSE_POW +MAPPING_1_MEASUREMENT=PV +MAPPING_1_FIELD=house_power +MAPPING_2_TYPE=integer +# +MAPPING_2_TOPIC=senec/0/ENERGY/GUI_GRID_POW +MAPPING_2_MEASUREMENT_POSITIVE=PV +MAPPING_2_MEASUREMENT_NEGATIVE=PV +MAPPING_2_FIELD_POSITIVE=grid_import_power +MAPPING_2_FIELD_NEGATIVE=grid_export_power +MAPPING_2_TYPE=integer +# +MAPPING_3_TOPIC=senec/0/PV1/POWER_RATIO +MAPPING_3_MEASUREMENT=PV +MAPPING_3_FIELD=grid_export_limit +MAPPING_3_TYPE=float +# +MAPPING_4_TOPIC=senec/0/ENERGY/GUI_BAT_DATA_POWER +MAPPING_4_MEASUREMENT_POSITIVE=PV +MAPPING_4_MEASUREMENT_NEGATIVE=PV +MAPPING_4_FIELD_POSITIVE=battery_charging_power +MAPPING_4_FIELD_NEGATIVE=battery_discharging_power +MAPPING_4_TYPE=integer +# +MAPPING_5_TOPIC=senec/0/ENERGY/GUI_BAT_DATA_FUEL_CHARGE +MAPPING_5_MEASUREMENT=PV +MAPPING_5_FIELD=battery_soc +MAPPING_5_TYPE=float +# +MAPPING_6_TOPIC=senec/0/WALLBOX/APPARENT_CHARGING_POWER/0 +MAPPING_6_MEASUREMENT=PV +MAPPING_6_FIELD=wallbox_power +MAPPING_6_TYPE=integer +# +MAPPING_7_TOPIC=somewhere/HEATPUMP/POWER +MAPPING_7_MEASUREMENT=HEATPUMP +MAPPING_7_FIELD=power +MAPPING_7_TYPE=integer +# +MAPPING_8_TOPIC=senec/0/TEMPMEASURE/CASE_TEMP +MAPPING_8_MEASUREMENT=PV +MAPPING_8_FIELD=case_temp +MAPPING_8_TYPE=float +# +MAPPING_9_TOPIC=senec/0/ENERGY/STAT_STATE_Text +MAPPING_9_MEASUREMENT=PV +MAPPING_9_FIELD=system_status +MAPPING_9_TYPE=string +# +MAPPING_10_TOPIC=senec/0/ENERGY/STAT_STATE_Ok +MAPPING_10_MEASUREMENT=PV +MAPPING_10_FIELD=system_status_ok +MAPPING_10_TYPE=boolean ##### InfluxDB credentials INFLUX_HOST=localhost @@ -40,4 +161,3 @@ INFLUX_PORT=8086 INFLUX_TOKEN=my-token INFLUX_ORG=my-org INFLUX_BUCKET=my-bucket -INFLUX_MEASUREMENT=my-measurement diff --git a/.env.test b/.env.test index 66e3034..63cc28b 100644 --- a/.env.test +++ b/.env.test @@ -6,19 +6,59 @@ MQTT_USERNAME=my-username MQTT_PASSWORD=my-password # MQTT Topics -MQTT_TOPIC_HOUSE_POW=senec/0/ENERGY/GUI_HOUSE_POW -MQTT_TOPIC_GRID_POW=senec/0/ENERGY/GUI_GRID_POW -MQTT_TOPIC_BAT_CHARGE_CURRENT=senec/0/ENERGY/GUI_BAT_DATA_CURRENT -MQTT_TOPIC_BAT_FUEL_CHARGE=senec/0/ENERGY/GUI_BAT_DATA_FUEL_CHARGE -MQTT_TOPIC_BAT_POWER=senec/0/ENERGY/GUI_BAT_DATA_POWER -MQTT_TOPIC_BAT_VOLTAGE=senec/0/ENERGY/GUI_BAT_DATA_VOLTAGE -MQTT_TOPIC_CASE_TEMP=senec/0/TEMPMEASURE/CASE_TEMP -MQTT_TOPIC_CURRENT_STATE=senec/0/ENERGY/STAT_STATE_Text -MQTT_TOPIC_MPP1_POWER=senec/0/PV1/MPP_POWER/0 -MQTT_TOPIC_MPP2_POWER=senec/0/PV1/MPP_POWER/1 -MQTT_TOPIC_MPP3_POWER=senec/0/PV1/MPP_POWER/2 -MQTT_TOPIC_INVERTER_POWER=senec/0/ENERGY/GUI_INVERTER_POWER -MQTT_TOPIC_WALLBOX_CHARGE_POWER=senec/0/WALLBOX/APPARENT_CHARGING_POWER/0 +MAPPING_0_TOPIC=senec/0/ENERGY/GUI_INVERTER_POWER +MAPPING_0_MEASUREMENT=PV +MAPPING_0_FIELD=inverter_power +MAPPING_0_TYPE=integer +# +MAPPING_1_TOPIC=senec/0/ENERGY/GUI_HOUSE_POW +MAPPING_1_MEASUREMENT=PV +MAPPING_1_FIELD=house_power +MAPPING_1_TYPE=integer +# +MAPPING_2_TOPIC=senec/0/ENERGY/GUI_GRID_POW +MAPPING_2_MEASUREMENT_POSITIVE=PV +MAPPING_2_MEASUREMENT_NEGATIVE=PV +MAPPING_2_FIELD_POSITIVE=grid_import_power +MAPPING_2_FIELD_NEGATIVE=grid_export_power +MAPPING_2_TYPE=integer +# +MAPPING_3_TOPIC=senec/0/PV1/POWER_RATIO +MAPPING_3_MEASUREMENT=PV +MAPPING_3_FIELD=grid_export_limit +MAPPING_3_TYPE=float +# +MAPPING_4_TOPIC=senec/0/ENERGY/GUI_BAT_DATA_POWER +MAPPING_4_MEASUREMENT_POSITIVE=PV +MAPPING_4_MEASUREMENT_NEGATIVE=PV +MAPPING_4_FIELD_POSITIVE=battery_charging_power +MAPPING_4_FIELD_NEGATIVE=battery_discharging_power +MAPPING_4_TYPE=integer +# +MAPPING_5_TOPIC=senec/0/ENERGY/GUI_BAT_DATA_FUEL_CHARGE +MAPPING_5_MEASUREMENT=PV +MAPPING_5_FIELD=battery_soc +MAPPING_5_TYPE=float +# +MAPPING_6_TOPIC=senec/0/WALLBOX/APPARENT_CHARGING_POWER/0 +MAPPING_6_MEASUREMENT=PV +MAPPING_6_FIELD=wallbox_power +MAPPING_6_TYPE=integer +# +MAPPING_7_TOPIC=somewhere/HEATPUMP/POWER +MAPPING_7_MEASUREMENT=HEATPUMP +MAPPING_7_FIELD=power +MAPPING_7_TYPE=integer +# +MAPPING_8_TOPIC=senec/0/TEMPMEASURE/CASE_TEMP +MAPPING_8_MEASUREMENT=PV +MAPPING_8_FIELD=case_temp +MAPPING_8_TYPE=float +# +MAPPING_9_TOPIC=senec/0/ENERGY/STAT_STATE_Text +MAPPING_9_MEASUREMENT=PV +MAPPING_9_FIELD=system_status +MAPPING_9_TYPE=string # InfluxDB credentials INFLUX_HOST=localhost @@ -27,4 +67,3 @@ INFLUX_PORT=8086 INFLUX_TOKEN=my-token INFLUX_ORG=my-org INFLUX_BUCKET=my-bucket -INFLUX_MEASUREMENT=my-measurement diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml index 46eb026..ae7ae6b 100644 --- a/.github/workflows/automerge.yml +++ b/.github/workflows/automerge.yml @@ -12,7 +12,7 @@ jobs: steps: - name: Dependabot metadata id: metadata - uses: dependabot/fetch-metadata@v1.6.0 + uses: dependabot/fetch-metadata@v2.2.0 with: github-token: '${{ secrets.PAT }}' diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 4e0de44..11843cb 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -18,9 +18,13 @@ jobs: test: runs-on: ubuntu-latest + env: + CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} + CI: true + steps: - name: Checkout the code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -31,7 +35,13 @@ jobs: run: bundle exec rubocop - name: Run tests - run: bundle exec rake test + run: bundle exec rake spec + + - name: Send test coverage to CodeClimate + uses: paambaati/codeclimate-action@v8.0.0 + if: ${{ env.CC_TEST_REPORTER_ID }} + with: + coverageCommand: true build: runs-on: ubuntu-latest @@ -40,13 +50,13 @@ jobs: steps: - name: Checkout the code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - name: Docker meta id: meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@v5 with: # list of Docker images to use as base name for tags images: | @@ -61,25 +71,26 @@ jobs: type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') }} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 with: platforms: linux/amd64,linux/arm64,linux/arm/v7 - name: Login to GitHub Container Registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.CR_PAT }} - name: Build and push - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm64,linux/arm/v7 + provenance: false push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} @@ -87,3 +98,5 @@ jobs: BUILDTIME=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }} VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }} REVISION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..c99d2e7 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/.rubocop.yml b/.rubocop.yml index 5a62feb..76278c4 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,32 +1,85 @@ require: - - rubocop-minitest + - rubocop-rspec - rubocop-rake - rubocop-performance AllCops: - TargetRubyVersion: 3.2 + TargetRubyVersion: 3.3 Exclude: - Gemfile - 'vendor/**/*' NewCops: enable -Style/Documentation: +# Layout + +Layout/LineLength: + Max: 130 + +Layout/LineEndStringConcatenationIndentation: Enabled: false -Style/FrozenStringLiteralComment: +Layout/MultilineOperationIndentation: + Enabled: false + +Layout/EmptyComment: + Enabled: false + +# Metrics + +Metrics/AbcSize: + Max: 35 + +Metrics/ClassLength: Enabled: false Metrics/MethodLength: - Max: 20 + Max: 35 + +Metrics/BlockLength: + Max: 30 + +Metrics/CyclomaticComplexity: + Max: 10 + +Metrics/PerceivedComplexity: + Max: 10 + +# Style + +Style/Documentation: + Enabled: false + +Style/FrozenStringLiteralComment: + Enabled: false Style/TrailingCommaInArguments: EnforcedStyleForMultiline: consistent_comma +Style/TrailingCommaInArrayLiteral: + EnforcedStyleForMultiline: consistent_comma + Style/TrailingCommaInHashLiteral: EnforcedStyleForMultiline: consistent_comma -Layout/LineEndStringConcatenationIndentation: +Style/IfUnlessModifier: + Enabled: false + +Style/MultilineBlockChain: + Enabled: false + +Style/EmptyMethod: + Enabled: false + +# RSpec + +RSpec/ExampleLength: + Enabled: false + +RSpec/NestedGroups: + Max: 4 + +RSpec/NoExpectationExample: Enabled: false -Layout/FirstArrayElementIndentation: +RSpec/MultipleExpectations: Enabled: false diff --git a/.ruby-version b/.ruby-version index be94e6f..a0891f5 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.2.2 +3.3.4 diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..50a7b34 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,45 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "guard-rspec", + "command": "rbenv", + "args": ["exec", "bundle", "exec", "guard"], + "isBackground": true, + "problemMatcher": { + "applyTo": "allDocuments", + "owner": "Ruby", + "fileLocation": ["relative", "${workspaceRoot}"], + "pattern": [ + { + "regexp": "^(Error|Warning|Info):.*$", + "severity": 1 + }, + { + "regexp": "^\\s*[^#]+#[^:]+:$", + "message": 0 + }, + { + "regexp": "^\\s*([^:]+):(.*)$", + "message": 5 + }, + { + "regexp": "^ ([^:]+):(\\d+):in (.+)$", + "file": 1, + "location": 2, + "code": 3 + } + ], + "background": { + "activeOnStart": true, + "beginsPattern": "^# Running:$", + "endsPattern": "^\\d+ runs.*$" + } + }, + "group": { + "kind": "test", + "isDefault": true + } + } + ] +} diff --git a/Dockerfile b/Dockerfile index c8205c4..9bd7760 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ruby:3.2.2-alpine AS Builder +FROM ruby:3.3.4-alpine AS Builder RUN apk add --no-cache build-base WORKDIR /mqtt-collector @@ -8,9 +8,12 @@ RUN bundle config --local frozen 1 && \ bundle install -j4 --retry 3 && \ bundle clean --force -FROM ruby:3.2.2-alpine +FROM ruby:3.3.4-alpine LABEL maintainer="georg@ledermann.dev" +# Add tzdata to get correct timezone +RUN apk add --no-cache tzdata + # Decrease memory usage ENV MALLOC_ARENA_MAX 2 @@ -29,4 +32,4 @@ WORKDIR /mqtt-collector COPY --from=Builder /usr/local/bundle/ /usr/local/bundle/ COPY . /mqtt-collector/ -ENTRYPOINT bundle exec app/main.rb +ENTRYPOINT bundle exec app.rb diff --git a/Gemfile b/Gemfile index 7930a8e..a4cd0b7 100644 --- a/Gemfile +++ b/Gemfile @@ -9,9 +9,32 @@ gem 'influxdb-client' # Implementation of the MQTT protocol (https://github.com/njh/ruby-mqtt) gem 'mqtt' +# CSV Reading and Writing (https://github.com/ruby/csv) +gem 'csv' + +# Support for encoding and decoding binary data using a Base64 representation. (https://github.com/ruby/base64) +gem 'base64' + +# A formula language parser and evaluator (http://github.com/rubysolo/dentaku) +gem 'dentaku' + +# Arbitrary-precision decimal floating-point number library. (https://github.com/ruby/bigdecimal) +gem 'bigdecimal' + +# Ruby implementation of http://goessner.net/articles/JsonPath/ (https://github.com/joshbuddy/jsonpath) +gem 'jsonpath' + +group :development do + # Guard gem for RSpec (https://github.com/guard/guard-rspec) + gem 'guard-rspec', require: false + + # Pretty print Ruby objects with proper indentation and colors (https://github.com/amazing-print/amazing_print) + gem 'amazing_print' +end + group :development, :test do - # minitest provides a complete suite of testing facilities supporting TDD, BDD, mocking, and benchmarking (https://github.com/minitest/minitest) - gem 'minitest' + # rspec-3.13.0 (http://github.com/rspec) + gem 'rspec' # Rake is a Make-like program implemented in Ruby (https://github.com/ruby/rake) gem 'rake' @@ -19,24 +42,21 @@ group :development, :test do # Automatic Ruby code style checking tool. (https://github.com/rubocop/rubocop) gem 'rubocop' - # Automatic Minitest code style checking tool. - gem 'rubocop-minitest' - # A RuboCop plugin for Rake (https://github.com/rubocop/rubocop-rake) gem 'rubocop-rake' # Automatic performance checking tool for Ruby code. (https://github.com/rubocop/rubocop-performance) gem 'rubocop-performance' + # Code style checking for RSpec files (https://github.com/rubocop/rubocop-rspec) + gem 'rubocop-rspec' + # Record your test suite's HTTP interactions and replay them during future test runs for fast, deterministic, accurate tests. (https://benoittgt.github.io/vcr) gem 'vcr' # Library for stubbing HTTP requests in Ruby. (https://github.com/bblimke/webmock) gem 'webmock' - # Modify your ENV easily with ClimateControl (https://github.com/thoughtbot/climate_control) - gem 'climate_control' - # Code coverage for Ruby (https://github.com/simplecov-ruby/simplecov) gem 'simplecov' end diff --git a/Gemfile.lock b/Gemfile.lock index 5a6dd15..d3b2039 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,60 +1,119 @@ GEM remote: https://rubygems.org/ specs: - addressable (2.8.4) - public_suffix (>= 2.0.2, < 6.0) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + amazing_print (1.6.0) ast (2.4.2) - climate_control (1.2.0) - crack (0.4.5) + base64 (0.2.0) + bigdecimal (3.1.8) + coderay (1.1.3) + concurrent-ruby (1.3.3) + crack (1.0.0) + bigdecimal rexml + csv (3.3.0) + dentaku (3.5.3) + concurrent-ruby + diff-lcs (1.5.1) docile (1.4.0) - dotenv (2.8.1) - hashdiff (1.0.1) - influxdb-client (2.9.0) - json (2.6.3) + dotenv (3.1.2) + ffi (1.17.0) + formatador (1.1.0) + guard (2.18.1) + formatador (>= 0.2.4) + listen (>= 2.7, < 4.0) + lumberjack (>= 1.0.12, < 2.0) + nenv (~> 0.1) + notiffany (~> 0.0) + pry (>= 0.13.0) + shellany (~> 0.0) + thor (>= 0.18.1) + guard-compat (1.2.1) + guard-rspec (4.7.3) + guard (~> 2.1) + guard-compat (~> 1.1) + rspec (>= 2.99.0, < 4.0) + hashdiff (1.1.0) + influxdb-client (3.1.0) + json (2.7.2) + jsonpath (1.1.5) + multi_json language_server-protocol (3.17.0.3) - minitest (5.18.1) + listen (3.9.0) + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) + lumberjack (1.2.10) + method_source (1.1.0) mqtt (0.6.0) - parallel (1.23.0) - parser (3.2.2.3) + multi_json (1.15.0) + nenv (0.3.0) + notiffany (0.1.3) + nenv (~> 0.1) + shellany (~> 0.0) + parallel (1.25.1) + parser (3.3.4.0) ast (~> 2.4.1) racc - public_suffix (5.0.1) - racc (1.7.1) + pry (0.14.2) + coderay (~> 1.1) + method_source (~> 1.0) + public_suffix (6.0.0) + racc (1.8.0) rainbow (3.1.1) - rake (13.0.6) - regexp_parser (2.8.1) - rexml (3.2.5) - rubocop (1.54.1) + rake (13.2.1) + rb-fsevent (0.11.2) + rb-inotify (0.11.1) + ffi (~> 1.0) + regexp_parser (2.9.2) + rexml (3.3.1) + strscan + rspec (3.13.0) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.0) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.1) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.1) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.1) + rubocop (1.65.0) json (~> 2.3) language_server-protocol (>= 3.17.0) parallel (~> 1.10) - parser (>= 3.2.2.3) + parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.8, < 3.0) + regexp_parser (>= 2.4, < 3.0) rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.28.0, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.29.0) - parser (>= 3.2.1.0) - rubocop-minitest (0.31.0) - rubocop (>= 1.39, < 2.0) - rubocop-performance (1.18.0) - rubocop (>= 1.7.0, < 2.0) - rubocop-ast (>= 0.4.0) + rubocop-ast (1.31.3) + parser (>= 3.3.1.0) + rubocop-performance (1.21.1) + rubocop (>= 1.48.1, < 2.0) + rubocop-ast (>= 1.31.1, < 2.0) rubocop-rake (0.6.0) rubocop (~> 1.0) + rubocop-rspec (3.0.3) + rubocop (~> 1.61) ruby-progressbar (1.13.0) + shellany (0.0.1) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) simplecov-html (0.12.3) simplecov_json_formatter (0.1.4) - unicode-display_width (2.4.2) + strscan (3.1.0) + thor (1.3.1) + unicode-display_width (2.5.0) vcr (6.2.0) - webmock (3.18.1) + webmock (3.23.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) @@ -63,19 +122,25 @@ PLATFORMS ruby DEPENDENCIES - climate_control + amazing_print + base64 + bigdecimal + csv + dentaku dotenv + guard-rspec influxdb-client - minitest + jsonpath mqtt rake + rspec rubocop - rubocop-minitest rubocop-performance rubocop-rake + rubocop-rspec simplecov vcr webmock BUNDLED WITH - 2.4.15 + 2.5.15 diff --git a/Guardfile b/Guardfile new file mode 100644 index 0000000..94b9064 --- /dev/null +++ b/Guardfile @@ -0,0 +1,43 @@ +# A sample Guardfile +# More info at https://github.com/guard/guard#readme + +## Uncomment and set this to only include directories you want to watch +# directories %w(app lib config test spec features) \ +# .select{|d| Dir.exist?(d) ? d : UI.warning("Directory #{d} does not exist")} + +## Note: if you are using the `directories` clause above and you are not +## watching the project directory ('.'), then you will want to move +## the Guardfile to a watched dir and symlink it back, e.g. +# +# $ mkdir config +# $ mv Guardfile config/ +# $ ln -s config/Guardfile . +# +# and, you'll have to watch "config/Guardfile" instead of "Guardfile" + +# Ignore unneeded folders to prevent high CPU load +# https://stackoverflow.com/a/20543493/57950 +ignore([%r{^coverage/*}, %r{^.vscode/*}, %r{^.github/*}]) + +# NOTE: The cmd option is now required due to the increasing number of ways +# rspec may be run, below are examples of the most common uses. +# * bundler: 'bundle exec rspec' +# * bundler binstubs: 'bin/rspec' +# * spring: 'bin/rspec' (This will use spring if running and you have +# installed the spring binstubs per the docs) +# * zeus: 'zeus rspec' (requires the server to be started separately) +# * 'just' rspec: 'rspec' + +guard :rspec, cmd: 'rspec --colour --format documentation --fail-fast' do + directories(%w[lib spec]) + + require 'guard/rspec/dsl' + dsl = Guard::RSpec::Dsl.new(self) + + # RSpec files + rspec = dsl.rspec + watch(rspec.spec_files) + watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" } + watch(rspec.spec_helper) { rspec.spec_dir } + watch(rspec.spec_support) { rspec.spec_dir } +end diff --git a/LICENSE b/LICENSE index f7a2d2b..add7ec7 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 Georg Ledermann and contributors +Copyright (c) 2023-2024 Georg Ledermann and contributors Inspired by code provided by Sebastian Löb (@loebse) and Michael Heß (@GrimmiMeloni) Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/README.md b/README.md index 5a61793..d1b5048 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,19 @@ [![Continuous integration](https://github.com/solectrus/mqtt-collector/actions/workflows/push.yml/badge.svg)](https://github.com/solectrus/mqtt-collector/actions/workflows/push.yml) +[![Maintainability](https://api.codeclimate.com/v1/badges/22171f55998309dcdfe1/maintainability)](https://codeclimate.com/github/solectrus/mqtt-collector/maintainability) [![wakatime](https://wakatime.com/badge/user/697af4f5-617a-446d-ba58-407e7f3e0243/project/233968fc-9ac5-4c50-952f-ec1a37b3df85.svg)](https://wakatime.com/badge/user/697af4f5-617a-446d-ba58-407e7f3e0243/project/233968fc-9ac5-4c50-952f-ec1a37b3df85) +[![Test Coverage](https://api.codeclimate.com/v1/badges/22171f55998309dcdfe1/test_coverage)](https://codeclimate.com/github/solectrus/mqtt-collector/test_coverage) # MQTT collector -Collect data from MQTT broker and push it to InfluxDB 2 for use with SOLECTRUS. +Collect data from MQTT broker and push it to InfluxDB 2. The mappings of MQTT topics to InfluxDB fields and measurements is customizable. -**BEWARE:** This project has just started and should be considered experimental. If you encounter any problems, please [open an issue](https://github.com/solectrus/mqtt-collector/issues). +The main use case is to collect data for SOLECTRUS, but it can be used for other purposes as well, where you want to collect data from MQTT and store it in InfluxDB. -It has been roughly tested in the following setups: +It has been tested in the following setups: - [ioBroker](https://www.iobroker.net/) with the integrated MQTT broker and the [SENEC Home 2.1 adapter](https://github.com/nobl/ioBroker.senec) - [evcc](https://evcc.io/) with the [senec-home template](https://github.com/evcc-io/evcc/blob/master/templates/definition/meter/senec-home.yaml) and the [HiveMQ MQTT Broker](https://www.hivemq.com/public-mqtt-broker/) -I'm **very interested** in your feedback, especially if you are using other devices or MQTT brokers. Please [open an issue](https://github.com/solectrus/mqtt-collector/issues) or use the [forum](https://github.com/orgs/solectrus/discussions). - Note: For a SENEC device there is a dedicated [senec-collector](https://github.com/solectrus/senec-collector) available which communicates directly with the SENEC device via its API and does not require a MQTT broker. Also, it is able to collect additional and more accurate data from the SENEC device. ## Requirements @@ -34,7 +34,7 @@ Note: For a SENEC device there is a dedicated [senec-collector](https://github.c docker compose up ``` -The Docker image support multiple platforms: `linux/amd64`, `linux/arm64`, `linux/arm/v7` +The Docker image supports multiple platforms: `linux/amd64`, `linux/arm64`, `linux/arm/v7` ## Development @@ -43,7 +43,7 @@ For development you need a recent Ruby setup. On a Mac, I recommend [rbenv](http ### Run the app ```bash -bundle exec app/main.rb +bundle exec app.rb ``` ### Run tests @@ -58,15 +58,7 @@ bundle exec rake bundle exec rubocop ``` -### Build Docker image by yourself - -Example for Raspberry Pi: - -```bash -docker buildx build --platform linux/arm64 -t mqtt-collector . -``` - ## License -Copyright (c) 2023 Georg Ledermann and contributors.\ +Copyright (c) 2023-2024 Georg Ledermann and contributors.\ Inspired by code provided by Sebastian Löb (@loebse) and Michael Heß (@GrimmiMeloni) diff --git a/Rakefile b/Rakefile index c76a55c..cb9087e 100644 --- a/Rakefile +++ b/Rakefile @@ -1,13 +1,6 @@ -require 'rubygems' -require 'bundler' -require 'rake/testtask' -require 'dotenv' -Dotenv.load('.env.test') +require 'rake' +require 'rspec/core/rake_task' -Rake::TestTask.new :test do |t| - t.libs << 'test' << 'app' - t.test_files = FileList['test/**/*_test.rb'] - t.verbose = true -end +RSpec::Core::RakeTask.new(:spec) -task default: :test +task default: :spec diff --git a/app.rb b/app.rb new file mode 100755 index 0000000..7e71f44 --- /dev/null +++ b/app.rb @@ -0,0 +1,43 @@ +#!/usr/bin/env ruby + +require 'bundler/setup' +Bundler.require + +$LOAD_PATH.unshift(File.expand_path('./lib', __dir__)) + +require 'dotenv/load' +require 'loop' +require 'config' +require 'stdout_logger' + +logger = StdoutLogger.new + +logger.info 'MQTT collector for SOLECTRUS, ' \ + "Version #{ENV.fetch('VERSION', '')}, " \ + "built at #{ENV.fetch('BUILDTIME', '')}" +logger.info 'https://github.com/solectrus/mqtt-collector' +logger.info 'Copyright (c) 2023-2024 Georg Ledermann and contributors, released under the MIT License' +logger.info "\n" + +config = Config.new(ENV, logger:) + +logger.info "Using Ruby #{RUBY_VERSION} on platform #{RUBY_PLATFORM}" +logger.info "Subscribing from MQTT broker at #{config.mqtt_url}" +logger.info "Pushing to InfluxDB at #{config.influx_url}, " \ + "bucket #{config.influx_bucket}" +logger.info "\n" + +mapper = Mapper.new(config:) +if mapper.topics.empty? + logger.error 'No mappings defined - exiting.' + exit 1 +else + logger.info "Subscribing to #{mapper.topics.length} topics:" + max_length = mapper.topics.map(&:length).max + mapper.topics.each do |topic| + logger.info "- #{topic.ljust(max_length, ' ')} => #{mapper.formatted_mapping(topic)}" + end + logger.info "\n" +end + +Loop.new(config:).start diff --git a/app/config.rb b/app/config.rb deleted file mode 100644 index 7e66bbd..0000000 --- a/app/config.rb +++ /dev/null @@ -1,120 +0,0 @@ -require 'uri' - -Config = - Struct.new( - # MQTT credentials - :mqtt_host, - :mqtt_port, - :mqtt_username, - :mqtt_password, - :mqtt_ssl, - # MQTT topics - :mqtt_topic_inverter_power, - :mqtt_topic_mpp1_power, - :mqtt_topic_mpp2_power, - :mqtt_topic_mpp3_power, - :mqtt_topic_house_pow, - :mqtt_topic_bat_fuel_charge, - :mqtt_topic_wallbox_charge_power, - :mqtt_topic_bat_power, - :mqtt_topic_grid_pow, - :mqtt_topic_current_state, - :mqtt_topic_case_temp, - :mqtt_topic_bat_charge_current, - :mqtt_topic_bat_voltage, - # MQTT options - :mqtt_flip_bat_power, - # InfluxDB credentials - :influx_schema, - :influx_host, - :influx_port, - :influx_token, - :influx_org, - :influx_bucket, - :influx_measurement, - keyword_init: true, - ) do - def self.from_env(options = {}) - new( - {}.merge(mqtt_credentials_from_env) - .merge(mqtt_topics_from_env) - .merge(mqtt_options_from_env) - .merge(influx_credentials_from_env) - .merge(options), - ) - end - - def self.mqtt_credentials_from_env - { - mqtt_host: ENV.fetch('MQTT_HOST'), - mqtt_port: ENV.fetch('MQTT_PORT'), - mqtt_ssl: ENV.fetch('MQTT_SSL', 'false') == 'true', - mqtt_username: ENV.fetch('MQTT_USERNAME'), - mqtt_password: ENV.fetch('MQTT_PASSWORD'), - } - end - - def self.mqtt_topics_from_env - %i[ - inverter_power - mpp1_power - mpp2_power - mpp3_power - house_pow - bat_fuel_charge - wallbox_charge_power - bat_power - grid_pow - current_state - case_temp - bat_charge_current - bat_voltage - ].each_with_object({}) do |topic, hash| - value = ENV.fetch("MQTT_TOPIC_#{topic.to_s.upcase}", nil) - next unless value - - hash["mqtt_topic_#{topic}"] = value - end - end - - def self.mqtt_options_from_env - { mqtt_flip_bat_power: ENV.fetch('MQTT_FLIP_BAT_POWER', nil) == 'true' } - end - - def self.influx_credentials_from_env - { - influx_host: ENV.fetch('INFLUX_HOST'), - influx_schema: ENV.fetch('INFLUX_SCHEMA', 'http'), - influx_port: ENV.fetch('INFLUX_PORT', '8086'), - influx_token: ENV.fetch('INFLUX_TOKEN'), - influx_org: ENV.fetch('INFLUX_ORG'), - influx_bucket: ENV.fetch('INFLUX_BUCKET'), - influx_measurement: ENV.fetch('INFLUX_MEASUREMENT'), - } - end - - def initialize(*options) - super - - validate_url!(influx_url) - validate_url!(mqtt_url) - end - - def influx_url - "#{influx_schema}://#{influx_host}:#{influx_port}" - end - - def mqtt_url - "#{mqtt_schema}://#{mqtt_host}:#{mqtt_port}" - end - - private - - def mqtt_schema - mqtt_ssl ? 'mqtts' : 'mqtt' - end - - def validate_url!(url) - URI.parse(url) - end - end diff --git a/app/influx_push.rb b/app/influx_push.rb deleted file mode 100644 index f9708fc..0000000 --- a/app/influx_push.rb +++ /dev/null @@ -1,21 +0,0 @@ -require 'flux_writer' - -class InfluxPush - def initialize(config:) - @config = config - @flux_writer = FluxWriter.new(config) - end - - attr_reader :config, :flux_writer - - def call(record, time:) - flux_writer.push(record, time:) - rescue StandardError => e - puts "Error while pushing to InfluxDB: #{e.message}" - - # Wait a bit before trying again - sleep(5) - - retry - end -end diff --git a/app/loop.rb b/app/loop.rb deleted file mode 100644 index bb101d8..0000000 --- a/app/loop.rb +++ /dev/null @@ -1,80 +0,0 @@ -require 'mqtt' -require 'influxdb-client' -require 'influx_push' -require 'mapper' - -class Loop - def self.start(config:, max_count: nil) - if max_count - new(config:, max_count:).start - else - begin - new(config:).start - rescue MQTT::ProtocolException, Errno::ECONNRESET => e - puts "#{Time.now}: #{e}, retrying later..." - sleep(5) - # TODO: Use exponential backoff instead of fixed 5 seconds - # Maybe use this gem: https://github.com/kamui/retriable - - retry - end - end - end - - def initialize(config:, max_count: nil) - @config = config - @max_count = max_count - end - - attr_reader :config, :max_count - - def start - # Subscribe to all topics - mapper.topics.each { |topic| mqtt_client.subscribe(topic) } - - # (Mostly) endless loop to receive messages - count = 0 - loop do - time, fields = next_message - influx_push.call(fields, time: time.to_i) - - count += 1 - break if max_count && count >= max_count - end - end - - def next_message - topic, message = mqtt_client.get - - # There is no timestamp in the MQTT message, so we use the current time - time = Time.now - - fields = mapper.call(topic, message) - puts "#{time} #{fields}" - - [time, fields] - end - - def influx_push - @influx_push ||= InfluxPush.new(config:) - end - - def mqtt_client - @mqtt_client ||= MQTT::Client.connect(mqtt_credentials) - end - - def mqtt_credentials - { - host: config.mqtt_host, - port: config.mqtt_port, - ssl: config.mqtt_ssl, - username: config.mqtt_username, - password: config.mqtt_password, - client_id: "mqtt-collector-#{SecureRandom.hex(4)}", - } - end - - def mapper - @mapper ||= Mapper.new(config:) - end -end diff --git a/app/main.rb b/app/main.rb deleted file mode 100755 index 974a70e..0000000 --- a/app/main.rb +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env ruby - -$LOAD_PATH.unshift(File.expand_path('.', __dir__)) - -require 'dotenv/load' -require 'loop' -require 'config' - -# Flush output immediately -$stdout.sync = true - -puts 'MQTT collector for SOLECTRUS, ' \ - "Version #{ENV.fetch('VERSION', '')}, " \ - "built at #{ENV.fetch('BUILDTIME', '')}" -puts 'https://github.com/solectrus/mqtt-collector' -puts 'Copyright (c) 2023 Georg Ledermann and contributors, released under the MIT License' -puts "\n" - -config = Config.from_env - -puts "Using Ruby #{RUBY_VERSION} on platform #{RUBY_PLATFORM}" -puts "Subscribing from MQTT broker at #{config.mqtt_url}" -puts "Pushing to InfluxDB at #{config.influx_url}, bucket #{config.influx_bucket}" -puts "\n" - -Loop.start(config:) diff --git a/app/mapper.rb b/app/mapper.rb deleted file mode 100644 index 6ee082b..0000000 --- a/app/mapper.rb +++ /dev/null @@ -1,98 +0,0 @@ -class Mapper - def initialize(config:) - @config = config - end - - attr_reader :config - - def topics - topic_keys.filter_map { |key| config.send("mqtt_topic_#{key}") }.sort - end - - def call(topic, message) - method_name = topic_to_method(topic) - raise "Unknown topic: #{topic}" unless method_name - - send(method_name, message) - end - - private - - def topic_keys - private_methods.grep(/^map_/).map { |m| m.to_s.sub('map_', '') } - end - - def topic_to_method(topic) - method = topic_keys.find { |key| config.send("mqtt_topic_#{key}") == topic } - return unless method - - "map_#{method}" - end - - # To add a new mapping, add a new method here and a new config key in app/config.rb - # The method name must be prefixed with `map_` and the config key must be prefixed with `mqtt_topic_` - # The method must return a hash with field name and value to be sent to InfluxDB - - def map_inverter_power(value) - { 'inverter_power' => value.to_f.round } - end - - def map_mpp1_power(value) - { 'mpp1_power' => value.to_f.round } - end - - def map_mpp2_power(value) - { 'mpp2_power' => value.to_f.round } - end - - def map_mpp3_power(value) - { 'mpp3_power' => value.to_f.round } - end - - def map_house_pow(value) - { 'house_power' => value.to_f.round } - end - - def map_bat_fuel_charge(value) - { 'bat_fuel_charge' => value.to_f.round(1) } - end - - def map_wallbox_charge_power(value) - { 'wallbox_charge_power' => value.to_f.round } - end - - def map_bat_power(value) - value = value.to_f.round - value = -value if config.mqtt_flip_bat_power - - if value.negative? - # From battery - { 'bat_power_plus' => 0, 'bat_power_minus' => -value } - else - # To battery - { 'bat_power_plus' => value, 'bat_power_minus' => 0 } - end - end - - def map_grid_pow(value) - value = value.to_f.round - - if value.negative? - # To grid - { 'grid_power_plus' => 0, 'grid_power_minus' => -value } - else - # From grid - { 'grid_power_plus' => value, 'grid_power_minus' => 0 } - end - end - - def map_current_state(value) - value.sub!(/ \(\d+\)/, '') - - { 'current_state' => value } - end - - def map_case_temp(value) - { 'case_temp' => value.to_f.round(1) } - end -end diff --git a/lib/config.rb b/lib/config.rb new file mode 100644 index 0000000..a1e2a35 --- /dev/null +++ b/lib/config.rb @@ -0,0 +1,201 @@ +require 'uri' +require 'null_logger' + +MAPPING_REGEX = /\AMAPPING_(\d+)_(.+)\z/ +MAPPING_TYPES = %w[integer float string boolean].freeze +DEPRECATED_ENV = { + 'MQTT_TOPIC_HOUSE_POW' => %w[house_power integer], + 'MQTT_TOPIC_GRID_POW' => %w[grid_power integer], + 'MQTT_TOPIC_BAT_FUEL_CHARGE' => %w[bat_fuel_charge float], + 'MQTT_TOPIC_BAT_POWER' => %w[bat_power integer], + 'MQTT_TOPIC_CASE_TEMP' => %w[case_temp float], + 'MQTT_TOPIC_CURRENT_STATE' => %w[current_state string], + 'MQTT_TOPIC_MPP1_POWER' => %w[mpp1_power integer], + 'MQTT_TOPIC_MPP2_POWER' => %w[mpp2_power integer], + 'MQTT_TOPIC_MPP3_POWER' => %w[mpp3_power integer], + 'MQTT_TOPIC_INVERTER_POWER' => %w[inverter_power integer], + 'MQTT_TOPIC_POWER_RATIO' => %w[power_ratio integer], + 'MQTT_TOPIC_WALLBOX_CHARGE_POWER' => %w[wallbox_charge_power integer], + 'MQTT_TOPIC_WALLBOX_CHARGE_POWER1' => %w[wallbox_charge_power1 integer], + 'MQTT_TOPIC_WALLBOX_CHARGE_POWER2' => %w[wallbox_charge_power2 integer], + 'MQTT_TOPIC_WALLBOX_CHARGE_POWER3' => %w[wallbox_charge_power3 integer], + 'MQTT_TOPIC_HEATPUMP_POWER' => %w[heatpump_power integer], +}.freeze + +class Config + attr_accessor :mqtt_host, + :mqtt_port, + :mqtt_username, + :mqtt_password, + :mqtt_ssl, + :mappings, + :influx_schema, + :influx_host, + :influx_port, + :influx_token, + :influx_org, + :influx_bucket + + def initialize(env, logger: NullLogger.new) + @logger = logger + + # MQTT Credentials + @mqtt_host = env.fetch('MQTT_HOST') + @mqtt_port = env.fetch('MQTT_PORT') + @mqtt_ssl = env.fetch('MQTT_SSL', 'false') == 'true' + @mqtt_username = env.fetch('MQTT_USERNAME', nil) + @mqtt_password = env.fetch('MQTT_PASSWORD', nil) + + # InfluxDB credentials + @influx_schema = env.fetch('INFLUX_SCHEMA', 'http') + @influx_host = env.fetch('INFLUX_HOST') + @influx_port = env.fetch('INFLUX_PORT', '8086') + @influx_token = env.fetch('INFLUX_TOKEN') + @influx_org = env.fetch('INFLUX_ORG') + @influx_bucket = env.fetch('INFLUX_BUCKET') + + # Mappings + @mappings = mappings_from(env) + deprecated_mappings_from(env) + + validate_url!(influx_url) + validate_url!(mqtt_url) + validate_mappings! + end + + def influx_url + "#{influx_schema}://#{influx_host}:#{influx_port}" + end + + def mqtt_url + "#{mqtt_schema}://#{mqtt_host}:#{mqtt_port}" + end + + attr_reader :logger + + private + + def mqtt_schema + mqtt_ssl ? 'mqtts' : 'mqtt' + end + + def mappings_from(env) + mapping_vars = env.select { |key, _| key.match?(MAPPING_REGEX) } + + mapping_groups = + mapping_vars.group_by { |key, _| key.match(MAPPING_REGEX)[1].to_i } + + mapping_groups + .transform_values do |values| + values.to_h.transform_keys do |key| + key.match(MAPPING_REGEX)[2].downcase.to_sym + end + end + .values + end + + def deprecated_mappings_from(env) + # Start index at the last existing mapping + index = mappings_from(env).length - 1 + + DEPRECATED_ENV.reduce([]) do |mappings, (var, field_and_type)| + next mappings unless env[var] + + options = deprecated_mapping(env, var, field_and_type) + deprecation_warning(var, index += 1, options) + mappings.push(options) + end + end + + def deprecated_mapping(env, var, field_and_type) + options = { topic: env[var] } + + case var + when 'MQTT_TOPIC_GRID_POW' + if env['MQTT_FLIP_GRID_POW'] == 'true' + options[:field_positive] = 'grid_power_minus' + options[:field_negative] = 'grid_power_plus' + else + options[:field_positive] = 'grid_power_plus' + options[:field_negative] = 'grid_power_minus' + end + options[:measurement_positive] = options[ + :measurement_negative + ] = env.fetch('INFLUX_MEASUREMENT') + when 'MQTT_TOPIC_BAT_POWER' + if env['MQTT_FLIP_BAT_POWER'] == 'true' + options[:field_positive] = 'bat_power_minus' + options[:field_negative] = 'bat_power_plus' + else + options[:field_positive] = 'bat_power_plus' + options[:field_negative] = 'bat_power_minus' + end + options[:measurement_positive] = options[ + :measurement_negative + ] = env.fetch('INFLUX_MEASUREMENT') + else + options[:field] = field_and_type[0] + options[:measurement] = env.fetch('INFLUX_MEASUREMENT') + end + + options[:type] = field_and_type[1] + options + end + + def deprecation_warning(var, index, options) + case var + when 'MQTT_TOPIC_GRID_POW', 'MQTT_TOPIC_BAT_POWER' + flip_var = + ( + if var == 'MQTT_TOPIC_GRID_POW' + 'MQTT_FLIP_GRID_POW' + else + 'MQTT_FLIP_BAT_POWER' + end + ) + + logger.warn "Variables #{var} and #{flip_var} are deprecated. " \ + 'To remove this warning, please replace the variables by:' + logger.warn " MAPPING_#{index}_TOPIC=#{options[:topic]}" + logger.warn " MAPPING_#{index}_FIELD_POSITIVE=#{options[:field_positive]}" + logger.warn " MAPPING_#{index}_FIELD_NEGATIVE=#{options[:field_negative]}" + logger.warn " MAPPING_#{index}_MEASUREMENT_POSITIVE=#{options[:measurement_positive]}" + logger.warn " MAPPING_#{index}_MEASUREMENT_NEGATIVE=#{options[:measurement_negative]}" + else + logger.warn "Variable #{var} is deprecated. To remove this warning, please replace the variable by:" + logger.warn " MAPPING_#{index}_TOPIC=#{options[:topic]}" + logger.warn " MAPPING_#{index}_FIELD=#{options[:field]}" + logger.warn " MAPPING_#{index}_MEASUREMENT=#{options[:measurement]}" + end + logger.warn " MAPPING_#{index}_TYPE=#{options[:type]}" + logger.warn '' + end + + def validate_url!(url) + URI.parse(url) + end + + def validate_mappings! + mappings.each do |value| + # Ensure all required keys are present + unless (value.keys & %i[topic measurement field type]).size == 4 || + ( + value.keys & + %i[ + topic + measurement_positive + measurement_negative + field_positive + field_negative + type + ] + ).size == 6 + raise ArgumentError, "Missing required keys: #{value.keys}" + end + + # Ensure type is valid + unless MAPPING_TYPES.include?(value[:type]) + raise ArgumentError, "Invalid type: #{value[:type]}" + end + end + end +end diff --git a/lib/evaluator.rb b/lib/evaluator.rb new file mode 100644 index 0000000..6de1f5b --- /dev/null +++ b/lib/evaluator.rb @@ -0,0 +1,49 @@ +class Evaluator + attr_reader :expression, :data + + def initialize(expression:, data:) + @expression = expression + @data = data + end + + def run + # Get variables used in the expression + variables = extract_variables_from_expression + + # Get values for each of this variables + values = extract_values_from_data(variables) + + # Evaluate the expression + Dentaku(normalized_expression, values) + end + + private + + def extract_variables_from_expression + expression.scan(/{(.*?)}/).flatten + end + + def extract_values_from_data(vars) + vars.to_h { |var| [normalized_variable(var), value(var)] } + end + + def value(variable) + if variable.start_with?('$.') + JsonPath.new(variable).first(data) + else + data[variable] + end + end + + # Replace all variables by their normalized version + def normalized_expression + expression.gsub(/{(.*?)}/) do |variable| + normalized_variable(variable) + end + end + + # Remove curly braces and replace all non-alphanumeric characters by underscore + def normalized_variable(variable) + variable.gsub(/[{}]/, '').gsub(/[^0-9a-z]/i, '_') + end +end diff --git a/app/flux_writer.rb b/lib/flux_writer.rb similarity index 66% rename from app/flux_writer.rb rename to lib/flux_writer.rb index 59e6d45..ebe1c17 100644 --- a/app/flux_writer.rb +++ b/lib/flux_writer.rb @@ -7,11 +7,9 @@ def initialize(config) attr_reader :config - def push(record, time:) - return unless record - + def push(records, time:) write_api.write( - data: point(record, time:), + data: points(records, time:), bucket: config.influx_bucket, org: config.influx_org, ) @@ -19,16 +17,16 @@ def push(record, time:) private - def point(record, time:) - InfluxDB2::Point.new( - name: influx_measurement, - time:, - fields: record.to_hash, - ) - end - - def influx_measurement - config.influx_measurement + def points(records, time:) + records.map do |record| + InfluxDB2::Point.new( + time:, + name: record[:measurement], + fields: { + record[:field] => record[:value], + }, + ) + end end def influx_client diff --git a/lib/influx_push.rb b/lib/influx_push.rb new file mode 100644 index 0000000..241772f --- /dev/null +++ b/lib/influx_push.rb @@ -0,0 +1,29 @@ +require 'flux_writer' +require 'forwardable' + +class InfluxPush + extend Forwardable + def_delegators :config, :logger + + def initialize(config:) + @config = config + @flux_writer = FluxWriter.new(config) + end + + attr_reader :config, :flux_writer + + def call(records, time:, retries: nil, retry_delay: 5) + retry_count = 0 + begin + flux_writer.push(records, time:) + rescue StandardError => e + logger.error "Error while pushing to InfluxDB: #{e.message}" + retry_count += 1 + + raise e if retries && retry_count > retries + + sleep(retry_delay) + retry + end + end +end diff --git a/lib/loop.rb b/lib/loop.rb new file mode 100644 index 0000000..9230caf --- /dev/null +++ b/lib/loop.rb @@ -0,0 +1,107 @@ +require 'mqtt' +require 'influxdb-client' +require 'influx_push' +require 'mapper' + +class Loop + extend Forwardable + def_delegators :config, :logger + + def initialize(config:, retry_wait: 5, max_count: nil) + @config = config + @max_count = max_count + @retry_wait = retry_wait + end + + attr_reader :config, :max_count, :retry_wait + + def start + subscribe_topics + receive_messages + rescue MQTT::ProtocolException, StandardError => e + handle_exception(e) + + sleep(retry_wait) + # TODO: Use exponential backoff instead of fixed timeout + # Maybe use this gem: https://github.com/kamui/retriable + + retry if max_count.nil? + rescue SystemExit, Interrupt + logger.warn 'Exiting...' + end + + def stop + mqtt_client&.disconnect + rescue MQTT::ProtocolException, StandardError => e + handle_exception(e) + end + + def subscribe_topics + # Subscribe to all topics + mapper.topics.each { |topic| mqtt_client.subscribe(topic) } + end + + def receive_messages + # (Mostly) endless loop to receive messages + count = 0 + loop do + time, records = next_message + influx_push.call(records, time: time.to_i) + + count += 1 + break if max_count && count >= max_count + end + end + + def next_message + topic, message = mqtt_client.get + + # There is no timestamp in the MQTT message, so we use the current time + time = Time.now + + records = mapper.records_for(topic, message) + + # Log all the data we received + logger.info "# Message from #{time}" + logger.info " topic = #{topic}" + logger.info " message = #{message}" + + # Log all the data we are going to push to InfluxDB + records.each do |record| + logger.info " => #{record[:measurement]}:#{record[:field]} = #{record[:value]}" + end + + [time, records] + end + + def influx_push + @influx_push ||= InfluxPush.new(config:) + end + + def mqtt_client + @mqtt_client ||= MQTT::Client.connect(mqtt_credentials) + end + + def mqtt_credentials + { + host: config.mqtt_host, + port: config.mqtt_port, + ssl: config.mqtt_ssl, + username: config.mqtt_username, + password: config.mqtt_password, + client_id: "mqtt-collector-#{SecureRandom.hex(4)}", + }.compact + end + + def mapper + @mapper ||= Mapper.new(config:) + end + + def handle_exception(error) + logger.error "#{Time.now}: #{error}, will retry again in #{retry_wait} seconds..." + + # Reset MQTT client, so it will reconnect next time + @mqtt_client&.disconnect + @mqtt_client = nil + end +end diff --git a/lib/mapper.rb b/lib/mapper.rb new file mode 100644 index 0000000..1c30bf3 --- /dev/null +++ b/lib/mapper.rb @@ -0,0 +1,146 @@ +require 'evaluator' + +class Mapper + def initialize(config:) + @config = config + end + + attr_reader :config + + def topics + @topics ||= config.mappings.map { |mapping| mapping[:topic] }.sort.uniq + end + + def formatted_mapping(topic) + mappings_for(topic) + .map do |mapping| + result = + if signed?(mapping) + "#{mapping[:measurement_positive]}:#{mapping[:field_positive]} (+) " \ + "#{mapping[:measurement_negative]}:#{mapping[:field_negative]} (-)" + else + "#{mapping[:measurement]}:#{mapping[:field]}" + end + + result += " (#{mapping[:type]})" + result + end + .join(', ') + end + + def records_for(topic, message) + return [] if message == '' + + mappings = mappings_for(topic) + raise "Unknown mapping for topic: #{topic}" if mappings.empty? + + mappings + .map do |mapping| + value = value_from(message, mapping) + if signed?(mapping) + map_with_sign(mapping, value) + else + map_default(mapping, value) + end + end + .flatten + .delete_if { |record| record[:value].nil? } + end + + private + + def signed?(mapping) + ( + mapping.keys & + %i[ + field_positive + field_negative + measurement_positive + measurement_negative + ] + ).size == 4 + end + + def value_from(message, mapping) + if mapping[:json_key] || mapping[:json_path] + message = extract_from_json(message, mapping) + elsif mapping[:json_formula] + message = evaluate_from_json(message, mapping) + end + + convert_type(message, mapping) if message + end + + def convert_type(message, mapping) + case mapping[:type] + when 'float' + begin + message.to_f + rescue StandardError + config.logger.warn "Failed to convert #{message} to float" + nil + end + when 'integer' + begin + message.to_f.round + rescue StandardError + config.logger.warn "Failed to convert #{message} to integer" + nil + end + when 'boolean' + %w[true ok yes on 1].include?(message.to_s.downcase) + when 'string' + message.to_s + end + end + + def extract_from_json(message, mapping) + raise "Message is not a string: #{message}" unless message.is_a? String + + json = parse_json(message) + return unless json + + if mapping[:json_path] + JsonPath.new(mapping[:json_path]).first(json) + elsif mapping[:json_key] + json[mapping[:json_key]] + end + end + + def evaluate_from_json(message, mapping) + json = parse_json(message) + return unless json + + Evaluator.new(expression: mapping[:json_formula], data: json).run + end + + def parse_json(message) + JSON.parse(message) + rescue JSON::ParserError + config.logger.warn "Failed to parse JSON: #{message}" + nil + end + + def map_with_sign(mapping, value) + [ + { + measurement: mapping[:measurement_negative], + field: mapping[:field_negative], + value: value.negative? ? value.abs : convert_type('0', mapping), + }, + { + measurement: mapping[:measurement_positive], + field: mapping[:field_positive], + value: value.positive? ? value : convert_type('0', mapping), + }, + ] + end + + def map_default(mapping, value) + [{ measurement: mapping[:measurement], field: mapping[:field], value: }] + end + + def mappings_for(topic) + config.mappings.select { |mapping| mapping[:topic] == topic } + end +end diff --git a/lib/null_logger.rb b/lib/null_logger.rb new file mode 100644 index 0000000..ee8a206 --- /dev/null +++ b/lib/null_logger.rb @@ -0,0 +1,13 @@ +class NullLogger + def info(message) + end + + def error(message) + end + + def debug(message) + end + + def warn(message) + end +end diff --git a/lib/stdout_logger.rb b/lib/stdout_logger.rb new file mode 100644 index 0000000..c64fb8b --- /dev/null +++ b/lib/stdout_logger.rb @@ -0,0 +1,25 @@ +class StdoutLogger + def initialize + # Flush output immediately + $stdout.sync = true + end + + def info(message) + puts message + end + + def error(message) + # Red text by using ANSI escape code + puts "\e[31m#{message}\e[0m" + end + + def debug(message) + # Blue text by using ANSI escape code + puts "\e[34m#{message}\e[0m" + end + + def warn(message) + # Yellow text by using ANSI escape code + puts "\e[33m#{message}\e[0m" + end +end diff --git a/test/cassettes/influx_success.yml b/spec/cassettes/influx_success.yml similarity index 72% rename from test/cassettes/influx_success.yml rename to spec/cassettes/influx_success.yml index f804df4..a9fc869 100644 --- a/test/cassettes/influx_success.yml +++ b/spec/cassettes/influx_success.yml @@ -5,14 +5,14 @@ http_interactions: uri: http://localhost:8086/api/v2/write?bucket=my-bucket&org=my-org&precision=s body: encoding: UTF-8 - string: my-measurement bat_fuel_charge=80.0 1684575773 + string: my-measurement bat_fuel_charge=80.0 1709027027 headers: Accept-Encoding: - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 Accept: - "*/*" User-Agent: - - influxdb-client-ruby/2.9.0 + - influxdb-client-ruby/3.0.0 Authorization: - Token my-token Content-Type: @@ -25,11 +25,11 @@ http_interactions: X-Influxdb-Build: - OSS X-Influxdb-Version: - - v2.7.1 + - v2.7.5 Date: - - Sat, 20 May 2023 09:42:53 GMT + - Tue, 27 Feb 2024 09:43:47 GMT body: encoding: UTF-8 string: '' - recorded_at: Sat, 20 May 2023 09:42:53 GMT -recorded_with: VCR 6.1.0 + recorded_at: Tue, 27 Feb 2024 09:43:47 GMT +recorded_with: VCR 6.2.0 diff --git a/spec/lib/config_mapping_spec.rb b/spec/lib/config_mapping_spec.rb new file mode 100644 index 0000000..01ae797 --- /dev/null +++ b/spec/lib/config_mapping_spec.rb @@ -0,0 +1,93 @@ +require 'config' + +describe Config, '#mapping' do + subject(:mappings) { config.mappings } + + let(:config) { described_class.new(env) } + + let(:other_env) do + { + 'MQTT_HOST' => '1.2.3.4', + 'MQTT_PORT' => '1883', + 'MQTT_USERNAME' => 'username', + 'MQTT_PASSWORD' => 'password', + 'MQTT_SSL' => 'false', + # --- + 'INFLUX_HOST' => 'influx.example.com', + 'INFLUX_SCHEMA' => 'https', + 'INFLUX_PORT' => '443', + 'INFLUX_TOKEN' => 'this.is.just.an.example', + 'INFLUX_ORG' => 'solectrus', + 'INFLUX_BUCKET' => 'my-bucket', + } + end + + context 'with valid mapping env' do + let(:env) do + other_env.merge( + { + 'MAPPING_0_TOPIC' => 'senec/0/ENERGY/GUI_INVERTER_POWER', + 'MAPPING_0_MEASUREMENT' => 'PV', + 'MAPPING_0_FIELD' => 'inverter_power', + 'MAPPING_0_TYPE' => 'integer', + 'MAPPING_1_TOPIC' => 'senec/0/ENERGY/GUI_HOUSE_POW', + 'MAPPING_1_MEASUREMENT' => 'PV', + 'MAPPING_1_FIELD' => 'house_power', + 'MAPPING_1_TYPE' => 'integer', + 'MAPPING_2_TOPIC' => 'senec/0/ENERGY/GUI_GRID_POW', + 'MAPPING_2_MEASUREMENT_POSITIVE' => 'PV', + 'MAPPING_2_MEASUREMENT_NEGATIVE' => 'PV', + 'MAPPING_2_FIELD_POSITIVE' => 'grid_import_power', + 'MAPPING_2_FIELD_NEGATIVE' => 'grid_export_power', + 'MAPPING_2_TYPE' => 'integer', + }, + ) + end + + it 'returns mappings as array' do + expect(mappings).to eq( + [ + { + topic: 'senec/0/ENERGY/GUI_INVERTER_POWER', + measurement: 'PV', + field: 'inverter_power', + type: 'integer', + }, + { + topic: 'senec/0/ENERGY/GUI_HOUSE_POW', + measurement: 'PV', + field: 'house_power', + type: 'integer', + }, + { + topic: 'senec/0/ENERGY/GUI_GRID_POW', + measurement_positive: 'PV', + measurement_negative: 'PV', + field_positive: 'grid_import_power', + field_negative: 'grid_export_power', + type: 'integer', + }, + ], + ) + end + end + + context 'with invalid mapping env' do + [ + { MAPPING_0_TOPIC: 'topic' }, + { MAPPING_0_TOPIC: 'topic', MAPPING_0_FIELD: 'field' }, + { MAPPING_0_TOPIC: 'topic', MAPPING_0_MEASUREMENT: 'measurement' }, + { + MAPPING_0_TOPIC: 'topic', + MAPPING_0_MEASUREMENT: 'measurement', + MAPPING_1_FIELD: 'field', + }, + ].each do |hash| + let(:env) { other_env.merge(hash) } + + it 'raises an error' do + expect { config }.to raise_error(ArgumentError) + end + end + end +end diff --git a/spec/lib/config_spec.rb b/spec/lib/config_spec.rb new file mode 100644 index 0000000..a151d3f --- /dev/null +++ b/spec/lib/config_spec.rb @@ -0,0 +1,479 @@ +require 'config' + +describe Config do + let(:config) { described_class.new(env) } + + let(:valid_env) do + { + 'MQTT_HOST' => '1.2.3.4', + 'MQTT_PORT' => '1883', + 'MQTT_USERNAME' => 'username', + 'MQTT_PASSWORD' => 'password', + 'MQTT_SSL' => 'false', + # --- + 'MAPPING_0_TOPIC' => 'senec/0/ENERGY/GUI_INVERTER_POWER', + 'MAPPING_0_MEASUREMENT' => 'PV', + 'MAPPING_0_FIELD' => 'inverter_power', + 'MAPPING_0_TYPE' => 'integer', + 'MAPPING_1_TOPIC' => 'senec/0/ENERGY/GUI_HOUSE_POW', + 'MAPPING_1_MEASUREMENT' => 'PV', + 'MAPPING_1_FIELD' => 'house_power', + 'MAPPING_1_TYPE' => 'integer', + 'MAPPING_2_TOPIC' => 'senec/0/ENERGY/GUI_GRID_POW', + 'MAPPING_2_MEASUREMENT_POSITIVE' => 'PV', + 'MAPPING_2_MEASUREMENT_NEGATIVE' => 'PV', + 'MAPPING_2_FIELD_POSITIVE' => 'grid_import_power', + 'MAPPING_2_FIELD_NEGATIVE' => 'grid_export_power', + 'MAPPING_2_TYPE' => 'integer', + 'MAPPING_3_TOPIC' => 'senec/0/PV1/POWER_RATIO', + 'MAPPING_3_MEASUREMENT' => 'PV', + 'MAPPING_3_FIELD' => 'grid_export_limit', + 'MAPPING_3_TYPE' => 'integer', + 'MAPPING_4_TOPIC' => 'senec/0/ENERGY/GUI_BAT_DATA_POWER', + 'MAPPING_4_MEASUREMENT_POSITIVE' => 'PV', + 'MAPPING_4_MEASUREMENT_NEGATIVE' => 'PV', + 'MAPPING_4_FIELD_POSITIVE' => 'battery_charging_power', + 'MAPPING_4_FIELD_NEGATIVE' => 'battery_discharging_power', + 'MAPPING_4_TYPE' => 'integer', + 'MAPPING_5_TOPIC' => 'senec/0/ENERGY/GUI_BAT_DATA_FUEL_CHARGE', + 'MAPPING_5_MEASUREMENT' => 'PV', + 'MAPPING_5_FIELD' => 'battery_soc', + 'MAPPING_5_TYPE' => 'float', + 'MAPPING_6_TOPIC' => 'senec/0/WALLBOX/APPARENT_CHARGING_POWER/0', + 'MAPPING_6_MEASUREMENT' => 'PV', + 'MAPPING_6_FIELD' => 'wallbox_power', + 'MAPPING_6_TYPE' => 'integer', + 'MAPPING_7_TOPIC' => 'somewhere/HEATPUMP/POWER', + 'MAPPING_7_MEASUREMENT' => 'HEATPUMP', + 'MAPPING_7_FIELD' => 'power', + 'MAPPING_7_TYPE' => 'integer', + 'MAPPING_8_TOPIC' => 'senec/0/TEMPMEASURE/CASE_TEMP', + 'MAPPING_8_MEASUREMENT' => 'PV', + 'MAPPING_8_FIELD' => 'case_temp', + 'MAPPING_8_TYPE' => 'float', + 'MAPPING_9_TOPIC' => 'senec/0/ENERGY/STAT_STATE_Text', + 'MAPPING_9_MEASUREMENT' => 'PV', + 'MAPPING_9_FIELD' => 'system_status', + 'MAPPING_9_TYPE' => 'string', + # --- + 'INFLUX_HOST' => 'influx.example.com', + 'INFLUX_SCHEMA' => 'https', + 'INFLUX_PORT' => '443', + 'INFLUX_TOKEN' => 'this.is.just.an.example', + 'INFLUX_ORG' => 'solectrus', + 'INFLUX_BUCKET' => 'my-bucket', + } + end + + let(:deprecated_env) do + { + 'MQTT_HOST' => '1.2.3.4', + 'MQTT_PORT' => '1883', + 'MQTT_USERNAME' => 'username', + 'MQTT_PASSWORD' => 'password', + 'MQTT_SSL' => 'false', + # --- + 'MQTT_TOPIC_HOUSE_POW' => 'senec/0/ENERGY/GUI_HOUSE_POW', + 'MQTT_TOPIC_GRID_POW' => 'senec/0/ENERGY/GUI_GRID_POW', + 'MQTT_TOPIC_BAT_FUEL_CHARGE' => 'senec/0/ENERGY/GUI_BAT_DATA_FUEL_CHARGE', + 'MQTT_TOPIC_BAT_POWER' => 'senec/0/ENERGY/GUI_BAT_DATA_POWER', + 'MQTT_TOPIC_CASE_TEMP' => 'senec/0/TEMPMEASURE/CASE_TEMP', + 'MQTT_TOPIC_CURRENT_STATE' => 'senec/0/ENERGY/STAT_STATE_Text', + 'MQTT_TOPIC_MPP1_POWER' => 'senec/0/PV1/MPP_POWER/0', + 'MQTT_TOPIC_MPP2_POWER' => 'senec/0/PV1/MPP_POWER/1', + 'MQTT_TOPIC_MPP3_POWER' => 'senec/0/PV1/MPP_POWER/2', + 'MQTT_TOPIC_INVERTER_POWER' => 'senec/0/ENERGY/GUI_INVERTER_POWER', + 'MQTT_TOPIC_WALLBOX_CHARGE_POWER' => + 'senec/0/WALLBOX/APPARENT_CHARGING_POWER/0', + 'MQTT_TOPIC_WALLBOX_CHARGE_POWER1' => + 'senec/0/WALLBOX/APPARENT_CHARGING_POWER/1', + 'MQTT_TOPIC_WALLBOX_CHARGE_POWER2' => + 'senec/0/WALLBOX/APPARENT_CHARGING_POWER/2', + 'MQTT_TOPIC_WALLBOX_CHARGE_POWER3' => + 'senec/0/WALLBOX/APPARENT_CHARGING_POWER/3', + 'MQTT_TOPIC_POWER_RATIO' => 'senec/0/PV1/POWER_RATIO', + 'MQTT_TOPIC_HEATPUMP_POWER' => 'somewhere/HEATPUMP/POWER', + # --- + 'MQTT_FLIP_BAT_POWER' => 'true', + 'MQTT_FLIP_GRID_POW' => 'true', + # --- + 'INFLUX_HOST' => 'influx.example.com', + 'INFLUX_SCHEMA' => 'https', + 'INFLUX_PORT' => '443', + 'INFLUX_TOKEN' => 'this.is.just.an.example', + 'INFLUX_ORG' => 'solectrus', + 'INFLUX_BUCKET' => 'my-bucket', + 'INFLUX_MEASUREMENT' => 'PV', + } + end + + describe 'valid options' do + let(:env) { valid_env } + + it 'initializes successfully' do + expect(config).to be_a(described_class) + end + end + + describe 'mqtt credentials' do + let(:env) { valid_env } + + it 'matches the environment variables' do + expect(config.mqtt_host).to eq(valid_env['MQTT_HOST']) + expect(config.mqtt_port).to eq(valid_env['MQTT_PORT']) + expect(config.mqtt_username).to eq(valid_env['MQTT_USERNAME']) + expect(config.mqtt_password).to eq(valid_env['MQTT_PASSWORD']) + expect(config.mqtt_url).to eq('mqtt://1.2.3.4:1883') + expect(config.mqtt_ssl).to be false + end + end + + describe '#mappings' do + context 'when using valid environment variables' do + let(:env) { valid_env } + + it 'matches the environment variables for MQTT topics' do + expect(config.mappings).to eq( + [ + { + topic: 'senec/0/ENERGY/GUI_INVERTER_POWER', + measurement: 'PV', + field: 'inverter_power', + type: 'integer', + }, + { + topic: 'senec/0/ENERGY/GUI_HOUSE_POW', + measurement: 'PV', + field: 'house_power', + type: 'integer', + }, + { + topic: 'senec/0/ENERGY/GUI_GRID_POW', + measurement_positive: 'PV', + measurement_negative: 'PV', + field_positive: 'grid_import_power', + field_negative: 'grid_export_power', + type: 'integer', + }, + { + topic: 'senec/0/PV1/POWER_RATIO', + measurement: 'PV', + field: 'grid_export_limit', + type: 'integer', + }, + { + topic: 'senec/0/ENERGY/GUI_BAT_DATA_POWER', + measurement_positive: 'PV', + measurement_negative: 'PV', + field_positive: 'battery_charging_power', + field_negative: 'battery_discharging_power', + type: 'integer', + }, + { + topic: 'senec/0/ENERGY/GUI_BAT_DATA_FUEL_CHARGE', + measurement: 'PV', + field: 'battery_soc', + type: 'float', + }, + { + topic: 'senec/0/WALLBOX/APPARENT_CHARGING_POWER/0', + measurement: 'PV', + field: 'wallbox_power', + type: 'integer', + }, + { + topic: 'somewhere/HEATPUMP/POWER', + measurement: 'HEATPUMP', + field: 'power', + type: 'integer', + }, + { + topic: 'senec/0/TEMPMEASURE/CASE_TEMP', + measurement: 'PV', + field: 'case_temp', + type: 'float', + }, + { + topic: 'senec/0/ENERGY/STAT_STATE_Text', + measurement: 'PV', + field: 'system_status', + type: 'string', + }, + ], + ) + end + end + + context 'when using deprecated environment variables (flipped)' do + let(:env) do + deprecated_env.merge( + 'MQTT_FLIP_BAT_POWER' => 'true', + 'MQTT_FLIP_GRID_POW' => 'true', + ) + end + + it 'builds mappings' do + expect(config.mappings).to eq( + [ + { + topic: 'senec/0/ENERGY/GUI_HOUSE_POW', + measurement: 'PV', + field: 'house_power', + type: 'integer', + }, + { + topic: 'senec/0/ENERGY/GUI_GRID_POW', + measurement_positive: 'PV', + measurement_negative: 'PV', + field_positive: 'grid_power_minus', + field_negative: 'grid_power_plus', + type: 'integer', + }, + { + topic: 'senec/0/ENERGY/GUI_BAT_DATA_FUEL_CHARGE', + measurement: 'PV', + field: 'bat_fuel_charge', + type: 'float', + }, + { + topic: 'senec/0/ENERGY/GUI_BAT_DATA_POWER', + measurement_negative: 'PV', + measurement_positive: 'PV', + field_positive: 'bat_power_minus', + field_negative: 'bat_power_plus', + type: 'integer', + }, + { + topic: 'senec/0/TEMPMEASURE/CASE_TEMP', + measurement: 'PV', + field: 'case_temp', + type: 'float', + }, + { + topic: 'senec/0/ENERGY/STAT_STATE_Text', + measurement: 'PV', + field: 'current_state', + type: 'string', + }, + { + topic: 'senec/0/PV1/MPP_POWER/0', + measurement: 'PV', + field: 'mpp1_power', + type: 'integer', + }, + { + topic: 'senec/0/PV1/MPP_POWER/1', + measurement: 'PV', + field: 'mpp2_power', + type: 'integer', + }, + { + topic: 'senec/0/PV1/MPP_POWER/2', + measurement: 'PV', + field: 'mpp3_power', + type: 'integer', + }, + { + topic: 'senec/0/ENERGY/GUI_INVERTER_POWER', + measurement: 'PV', + field: 'inverter_power', + type: 'integer', + }, + { + topic: 'senec/0/PV1/POWER_RATIO', + measurement: 'PV', + field: 'power_ratio', + type: 'integer', + }, + { + topic: 'senec/0/WALLBOX/APPARENT_CHARGING_POWER/0', + measurement: 'PV', + field: 'wallbox_charge_power', + type: 'integer', + }, + { + topic: 'senec/0/WALLBOX/APPARENT_CHARGING_POWER/1', + measurement: 'PV', + field: 'wallbox_charge_power1', + type: 'integer', + }, + { + topic: 'senec/0/WALLBOX/APPARENT_CHARGING_POWER/2', + measurement: 'PV', + field: 'wallbox_charge_power2', + type: 'integer', + }, + { + topic: 'senec/0/WALLBOX/APPARENT_CHARGING_POWER/3', + measurement: 'PV', + field: 'wallbox_charge_power3', + type: 'integer', + }, + { + topic: 'somewhere/HEATPUMP/POWER', + measurement: 'PV', + field: 'heatpump_power', + type: 'integer', + }, + ], + ) + end + end + + context 'when using deprecated environment variables (not flipped)' do + let(:env) do + deprecated_env.merge( + 'MQTT_FLIP_BAT_POWER' => 'false', + 'MQTT_FLIP_GRID_POW' => 'false', + ) + end + + it 'builds mappings' do + expect(config.mappings).to eq( + [ + { + topic: 'senec/0/ENERGY/GUI_HOUSE_POW', + measurement: 'PV', + field: 'house_power', + type: 'integer', + }, + { + topic: 'senec/0/ENERGY/GUI_GRID_POW', + measurement_positive: 'PV', + measurement_negative: 'PV', + field_positive: 'grid_power_plus', + field_negative: 'grid_power_minus', + type: 'integer', + }, + { + topic: 'senec/0/ENERGY/GUI_BAT_DATA_FUEL_CHARGE', + measurement: 'PV', + field: 'bat_fuel_charge', + type: 'float', + }, + { + topic: 'senec/0/ENERGY/GUI_BAT_DATA_POWER', + measurement_negative: 'PV', + measurement_positive: 'PV', + field_positive: 'bat_power_plus', + field_negative: 'bat_power_minus', + type: 'integer', + }, + { + topic: 'senec/0/TEMPMEASURE/CASE_TEMP', + measurement: 'PV', + field: 'case_temp', + type: 'float', + }, + { + topic: 'senec/0/ENERGY/STAT_STATE_Text', + measurement: 'PV', + field: 'current_state', + type: 'string', + }, + { + topic: 'senec/0/PV1/MPP_POWER/0', + measurement: 'PV', + field: 'mpp1_power', + type: 'integer', + }, + { + topic: 'senec/0/PV1/MPP_POWER/1', + measurement: 'PV', + field: 'mpp2_power', + type: 'integer', + }, + { + topic: 'senec/0/PV1/MPP_POWER/2', + measurement: 'PV', + field: 'mpp3_power', + type: 'integer', + }, + { + topic: 'senec/0/ENERGY/GUI_INVERTER_POWER', + measurement: 'PV', + field: 'inverter_power', + type: 'integer', + }, + { + topic: 'senec/0/PV1/POWER_RATIO', + measurement: 'PV', + field: 'power_ratio', + type: 'integer', + }, + { + topic: 'senec/0/WALLBOX/APPARENT_CHARGING_POWER/0', + measurement: 'PV', + field: 'wallbox_charge_power', + type: 'integer', + }, + { + topic: 'senec/0/WALLBOX/APPARENT_CHARGING_POWER/1', + measurement: 'PV', + field: 'wallbox_charge_power1', + type: 'integer', + }, + { + topic: 'senec/0/WALLBOX/APPARENT_CHARGING_POWER/2', + measurement: 'PV', + field: 'wallbox_charge_power2', + type: 'integer', + }, + { + topic: 'senec/0/WALLBOX/APPARENT_CHARGING_POWER/3', + measurement: 'PV', + field: 'wallbox_charge_power3', + type: 'integer', + }, + { + topic: 'somewhere/HEATPUMP/POWER', + measurement: 'PV', + field: 'heatpump_power', + type: 'integer', + }, + ], + ) + end + end + end + + describe 'Influx methods' do + let(:env) { valid_env } + + it 'matches the environment variables for Influx' do + expect(config.influx_host).to eq(valid_env['INFLUX_HOST']) + expect(config.influx_schema).to eq(valid_env['INFLUX_SCHEMA']) + expect(config.influx_port).to eq(valid_env['INFLUX_PORT']) + expect(config.influx_token).to eq(valid_env['INFLUX_TOKEN']) + expect(config.influx_org).to eq(valid_env['INFLUX_ORG']) + expect(config.influx_bucket).to eq(valid_env['INFLUX_BUCKET']) + expect(config.influx_url).to eq('https://influx.example.com:443') + end + end + + describe 'invalid options' do + context 'when all blank' do + let(:env) { {} } + + it 'raises an exception' do + expect { described_class.new(env) }.to raise_error(Exception) + end + end + + context 'when missing MQTT_HOST' do + let(:env) { valid_env.except('MQTT_HOST') } + + it 'raises an exception' do + expect { described_class.new(env) }.to raise_error(Exception) + end + end + + context 'when mapping type is invalid' do + let(:env) { valid_env.merge('MAPPING_0_TYPE' => 'this-is-no-type') } + + it 'raises an exception' do + expect { described_class.new(env) }.to raise_error(Exception) + end + end + end +end diff --git a/spec/lib/evaluator_spec.rb b/spec/lib/evaluator_spec.rb new file mode 100644 index 0000000..f074869 --- /dev/null +++ b/spec/lib/evaluator_spec.rb @@ -0,0 +1,95 @@ +require 'evaluator' + +describe Evaluator do + subject(:evaluator) { described_class.new(expression:, data:) } + + context 'with valid expression' do + let(:expression) { '({a} + {b}) / 2' } + let(:data) { { 'a' => 2, 'b' => 5 } } + + it 'evaluates the expression' do + expect(evaluator.run).to eq(3.5) + end + end + + context 'with valid expression using JSONPath' do + let(:expression) { '({$.a} + {$.b}) / 2' } + let(:data) { { 'a' => 2, 'b' => 5 } } + + it 'evaluates the expression' do + expect(evaluator.run).to eq(3.5) + end + end + + context 'with valid expression using more complex JSONPath' do + let(:expression) { '{$.c.d[1]} + {a}' } + let(:data) { { 'a' => 1, 'b' => 2, 'c' => { 'd' => [3, 42, 4] } } } + + it 'evaluates the expression' do + expect(evaluator.run).to eq(43) + end + end + + context 'with valid expression using vars with spaces' do + let(:expression) { '({this is a} + {this is b}) / 2' } + let(:data) { { 'this is a' => 2, 'this is b' => 5 } } + + it 'evaluates the expression' do + expect(evaluator.run).to eq(3.5) + end + end + + context 'with invalid expression (missing curley braces)' do + let(:expression) { '(a + b) / 2' } + let(:data) { { 'a' => 2, 'b' => 5 } } + + it 'returns nil' do + expect(evaluator.run).to be_nil + end + end + + context 'with invalid expression (open curley braces)' do + let(:expression) { '({a} + {b) / 2' } + let(:data) { { 'a' => 2, 'b' => 5 } } + + it 'returns nil' do + expect(evaluator.run).to be_nil + end + end + + context 'with invalid expression (missing parenthesis)' do + let(:expression) { '{a} + {b}) / 2' } + let(:data) { { 'a' => 2, 'b' => 5 } } + + it 'returns nil' do + expect(evaluator.run).to be_nil + end + end + + context 'with invalid expression (missing divisor)' do + let(:expression) { '({a} + {b}) / ' } + let(:data) { { 'a' => 2, 'b' => 5 } } + + it 'returns nil' do + expect(evaluator.run).to be_nil + end + end + + context 'with missing data' do + let(:expression) { '({a} + {b}) / 2' } + let(:data) { { 'a' => 2 } } + + it 'returns nil' do + expect(evaluator.run).to be_nil + end + end + + context 'with division by zero' do + let(:expression) { '({a} + {b}) / {b}' } + let(:data) { { 'a' => 2, 'b' => 0 } } + + it 'returns nil' do + expect(evaluator.run).to be_nil + end + end +end diff --git a/spec/lib/influx_push_spec.rb b/spec/lib/influx_push_spec.rb new file mode 100644 index 0000000..81f7e87 --- /dev/null +++ b/spec/lib/influx_push_spec.rb @@ -0,0 +1,37 @@ +require 'influx_push' +require 'config' + +describe InfluxPush do + subject(:influx_push) { described_class.new(config:) } + + let(:config) { Config.new(ENV, logger: MemoryLogger.new) } + + it 'initializes with a config' do + expect(influx_push.config).to eq(config) + end + + it 'can push records to InfluxDB', vcr: 'influx_success' do + time = Time.now + records = [{ fields: { key: 'value' } }] + + influx_push.call(records, time:) + end + + it 'can handle error' do + fake_flux = instance_double(FluxWriter) + allow(fake_flux).to receive(:push).and_raise(StandardError) + allow(FluxWriter).to receive(:new).and_return(fake_flux) + + time = Time.now + records = [{ fields: { key: 'value' } }] + + expect do + influx_push.call(records, time:, retries: 1, retry_delay: 0.1) + end.to raise_error(StandardError) + + expect(config.logger.error_messages).to include( + 'Error while pushing to InfluxDB: StandardError', + ) + sleep(1) + end +end diff --git a/spec/lib/loop_spec.rb b/spec/lib/loop_spec.rb new file mode 100644 index 0000000..e951d3b --- /dev/null +++ b/spec/lib/loop_spec.rb @@ -0,0 +1,61 @@ +require 'loop' +require 'config' + +describe Loop do + subject(:loop) { described_class.new(config:, max_count: 1, retry_wait: 1) } + + let(:config) do + Config.new( + ENV.to_h.merge('MQTT_HOST' => server.address, 'MQTT_PORT' => server.port), + logger:, + ) + end + let(:logger) { MemoryLogger.new } + + let(:server) do + server = MQTT::FakeServer.new + server.just_one_connection = true + server.logger = logger + server + end + + describe '#start' do + context 'when the MQTT server is running' do + before { server.start(payload_to_publish: '80.0') } + + after { server.stop } + + it 'handles payload', vcr: 'influx_success' do + loop.start + + expect(logger.info_messages).to include(/message = 80.0/) + expect(logger.info_messages).to include(/PV:battery_soc = 80.0/) + expect(logger.error_messages).to be_empty + + loop.stop + end + end + + context 'when the MQTT server is not running' do + it 'handles errors' do + loop.start + + expect(logger.error_messages).to include( + /Connection refused.*will retry again in 1 seconds/, + ) + + loop.stop + end + end + + context 'when interrupted' do + before { allow(MQTT::Client).to receive(:new).and_raise(Interrupt) } + + it 'handles interruption' do + loop.start + + expect(logger.warn_messages).to include(/Exiting/) + end + end + end +end diff --git a/spec/lib/mapper_spec.rb b/spec/lib/mapper_spec.rb new file mode 100644 index 0000000..6e75547 --- /dev/null +++ b/spec/lib/mapper_spec.rb @@ -0,0 +1,436 @@ +require 'mapper' +require 'config' + +VALID_ENV = { + 'MQTT_HOST' => '1.2.3.4', + 'MQTT_PORT' => '1883', + 'MQTT_USERNAME' => 'username', + 'MQTT_PASSWORD' => 'password', + 'MQTT_SSL' => 'false', + # --- + 'INFLUX_HOST' => 'influx.example.com', + 'INFLUX_SCHEMA' => 'https', + 'INFLUX_PORT' => '443', + 'INFLUX_TOKEN' => 'this.is.just.an.example', + 'INFLUX_ORG' => 'solectrus', + 'INFLUX_BUCKET' => 'my-bucket', + # --- + 'MAPPING_0_TOPIC' => 'senec/0/ENERGY/GUI_INVERTER_POWER', + 'MAPPING_0_MEASUREMENT' => 'PV', + 'MAPPING_0_FIELD' => 'inverter_power', + 'MAPPING_0_TYPE' => 'integer', + # + 'MAPPING_1_TOPIC' => 'senec/0/ENERGY/GUI_HOUSE_POW', + 'MAPPING_1_MEASUREMENT' => 'PV', + 'MAPPING_1_FIELD' => 'house_power', + 'MAPPING_1_TYPE' => 'integer', + # + 'MAPPING_2_TOPIC' => 'senec/0/ENERGY/GUI_GRID_POW', + 'MAPPING_2_MEASUREMENT_POSITIVE' => 'PV', + 'MAPPING_2_MEASUREMENT_NEGATIVE' => 'PV', + 'MAPPING_2_FIELD_POSITIVE' => 'grid_import_power', + 'MAPPING_2_FIELD_NEGATIVE' => 'grid_export_power', + 'MAPPING_2_TYPE' => 'integer', + # + 'MAPPING_3_TOPIC' => 'senec/0/PV1/POWER_RATIO', + 'MAPPING_3_MEASUREMENT' => 'PV', + 'MAPPING_3_FIELD' => 'grid_export_limit', + 'MAPPING_3_TYPE' => 'float', + # + 'MAPPING_4_TOPIC' => 'senec/0/ENERGY/GUI_BAT_DATA_POWER', + 'MAPPING_4_MEASUREMENT_POSITIVE' => 'PV', + 'MAPPING_4_MEASUREMENT_NEGATIVE' => 'PV', + 'MAPPING_4_FIELD_POSITIVE' => 'battery_charging_power', + 'MAPPING_4_FIELD_NEGATIVE' => 'battery_discharging_power', + 'MAPPING_4_TYPE' => 'float', + # + 'MAPPING_5_TOPIC' => 'senec/0/ENERGY/GUI_BAT_DATA_FUEL_CHARGE', + 'MAPPING_5_MEASUREMENT' => 'PV', + 'MAPPING_5_FIELD' => 'battery_soc', + 'MAPPING_5_TYPE' => 'float', + # + 'MAPPING_6_TOPIC' => 'senec/0/WALLBOX/APPARENT_CHARGING_POWER/0', + 'MAPPING_6_MEASUREMENT' => 'PV', + 'MAPPING_6_FIELD' => 'wallbox_power0', + 'MAPPING_6_TYPE' => 'integer', + # + 'MAPPING_7_TOPIC' => 'senec/0/WALLBOX/APPARENT_CHARGING_POWER/1', + 'MAPPING_7_MEASUREMENT' => 'PV', + 'MAPPING_7_FIELD' => 'wallbox_power1', + 'MAPPING_7_TYPE' => 'integer', + # + 'MAPPING_8_TOPIC' => 'senec/0/WALLBOX/APPARENT_CHARGING_POWER/2', + 'MAPPING_8_MEASUREMENT' => 'PV', + 'MAPPING_8_FIELD' => 'wallbox_power2', + 'MAPPING_8_TYPE' => 'integer', + # + 'MAPPING_9_TOPIC' => 'senec/0/WALLBOX/APPARENT_CHARGING_POWER/3', + 'MAPPING_9_MEASUREMENT' => 'PV', + 'MAPPING_9_FIELD' => 'wallbox_power3', + 'MAPPING_9_TYPE' => 'integer', + # + 'MAPPING_10_TOPIC' => 'somewhere/HEATPUMP/POWER', + 'MAPPING_10_MEASUREMENT' => 'HEATPUMP', + 'MAPPING_10_FIELD' => 'power', + 'MAPPING_10_TYPE' => 'integer', + # + 'MAPPING_11_TOPIC' => 'senec/0/TEMPMEASURE/CASE_TEMP', + 'MAPPING_11_MEASUREMENT' => 'PV', + 'MAPPING_11_FIELD' => 'case_temp', + 'MAPPING_11_TYPE' => 'float', + # + 'MAPPING_12_TOPIC' => 'senec/0/ENERGY/STAT_STATE_Text', + 'MAPPING_12_MEASUREMENT' => 'PV', + 'MAPPING_12_FIELD' => 'system_status', + 'MAPPING_12_TYPE' => 'string', + # + 'MAPPING_13_TOPIC' => 'senec/0/PV1/MPP_POWER/0', + 'MAPPING_13_MEASUREMENT' => 'PV', + 'MAPPING_13_FIELD' => 'mpp1_power', + 'MAPPING_13_TYPE' => 'integer', + # + 'MAPPING_14_TOPIC' => 'senec/0/PV1/MPP_POWER/1', + 'MAPPING_14_MEASUREMENT' => 'PV', + 'MAPPING_14_FIELD' => 'mpp2_power', + 'MAPPING_14_TYPE' => 'integer', + # + 'MAPPING_15_TOPIC' => 'senec/0/PV1/MPP_POWER/2', + 'MAPPING_15_MEASUREMENT' => 'PV', + 'MAPPING_15_FIELD' => 'mpp3_power', + 'MAPPING_15_TYPE' => 'integer', + # + 'MAPPING_16_TOPIC' => 'somewhere/STAT_STATE_OK', + 'MAPPING_16_MEASUREMENT' => 'PV', + 'MAPPING_16_FIELD' => 'system_status_ok', + 'MAPPING_16_TYPE' => 'boolean', + # + 'MAPPING_17_TOPIC' => 'somewhere/ATTR', + 'MAPPING_17_JSON_KEY' => 'leaving_temp', + 'MAPPING_17_MEASUREMENT' => 'HEATPUMP', + 'MAPPING_17_FIELD' => 'leaving_temp', + 'MAPPING_17_TYPE' => 'float', + # + 'MAPPING_18_TOPIC' => 'somewhere/ATTR', + 'MAPPING_18_JSON_KEY' => 'inlet_temp', + 'MAPPING_18_MEASUREMENT' => 'HEATPUMP', + 'MAPPING_18_FIELD' => 'inlet_temp', + 'MAPPING_18_TYPE' => 'float', + # + 'MAPPING_19_TOPIC' => 'somewhere/ATTR', + 'MAPPING_19_JSON_KEY' => 'water_flow', + 'MAPPING_19_MEASUREMENT' => 'HEATPUMP', + 'MAPPING_19_FIELD' => 'water_flow', + 'MAPPING_19_TYPE' => 'float', + # + 'MAPPING_20_TOPIC' => 'somewhere/ATTR', + 'MAPPING_20_JSON_FORMULA' => '{$.leaving_temp} - {$.inlet_temp}', + 'MAPPING_20_MEASUREMENT' => 'HEATPUMP', + 'MAPPING_20_FIELD' => 'temp_diff', + 'MAPPING_20_TYPE' => 'float', + # + 'MAPPING_21_TOPIC' => 'somewhere/ATTR', + 'MAPPING_21_JSON_FORMULA' => + 'round({water_flow} * 60.0 * 1.163 * ({leaving_temp} - {inlet_temp}))', + 'MAPPING_21_MEASUREMENT' => 'HEATPUMP', + 'MAPPING_21_FIELD' => 'heat', + 'MAPPING_21_TYPE' => 'float', + # + 'MAPPING_22_TOPIC' => 'go-e/ATTR', + 'MAPPING_22_JSON_PATH' => '$.ccp[6]', + 'MAPPING_22_MEASUREMENT' => 'WALLBOX', + 'MAPPING_22_FIELD' => 'power', + 'MAPPING_22_TYPE' => 'float', +}.freeze + +EXPECTED_TOPICS = %w[ + go-e/ATTR + senec/0/ENERGY/GUI_BAT_DATA_FUEL_CHARGE + senec/0/ENERGY/GUI_BAT_DATA_POWER + senec/0/ENERGY/GUI_GRID_POW + senec/0/ENERGY/GUI_HOUSE_POW + senec/0/ENERGY/GUI_INVERTER_POWER + senec/0/ENERGY/STAT_STATE_Text + senec/0/PV1/MPP_POWER/0 + senec/0/PV1/MPP_POWER/1 + senec/0/PV1/MPP_POWER/2 + senec/0/PV1/POWER_RATIO + senec/0/TEMPMEASURE/CASE_TEMP + senec/0/WALLBOX/APPARENT_CHARGING_POWER/0 + senec/0/WALLBOX/APPARENT_CHARGING_POWER/1 + senec/0/WALLBOX/APPARENT_CHARGING_POWER/2 + senec/0/WALLBOX/APPARENT_CHARGING_POWER/3 + somewhere/ATTR + somewhere/HEATPUMP/POWER + somewhere/STAT_STATE_OK +].freeze + +describe Mapper do + def default_config + @default_config ||= Config.new(VALID_ENV) + end + + def mapper(config: nil) + Mapper.new(config: config || default_config) + end + + it 'has topics' do + expect(mapper.topics).to eq(EXPECTED_TOPICS) + end + + it 'formats mapping' do + expect(mapper.formatted_mapping('senec/0/ENERGY/GUI_INVERTER_POWER')).to eq( + 'PV:inverter_power (integer)', + ) + + expect( + mapper.formatted_mapping('senec/0/ENERGY/GUI_BAT_DATA_FUEL_CHARGE'), + ).to eq('PV:battery_soc (float)') + + expect( + mapper.formatted_mapping('go-e/ATTR'), + ).to eq('WALLBOX:power (float)') + end + + it 'formats mapping with sign' do + expect(mapper.formatted_mapping('senec/0/ENERGY/GUI_GRID_POW')).to eq( + 'PV:grid_import_power (+) PV:grid_export_power (-) (integer)', + ) + end + + it 'formats mapping with multiple keys' do + expect(mapper.formatted_mapping('somewhere/ATTR')).to eq( + 'HEATPUMP:leaving_temp (float), ' \ + 'HEATPUMP:inlet_temp (float), ' \ + 'HEATPUMP:water_flow (float), ' \ + 'HEATPUMP:temp_diff (float), ' \ + 'HEATPUMP:heat (float)', + ) + end + + it 'maps inverter power' do + hash = mapper.records_for('senec/0/ENERGY/GUI_INVERTER_POWER', '123.45') + + expect(hash).to eq( + [{ field: 'inverter_power', measurement: 'PV', value: 123 }], + ) + end + + it 'maps mpp1_power' do + hash = mapper.records_for('senec/0/PV1/MPP_POWER/0', '123.45') + + expect(hash).to eq([{ field: 'mpp1_power', measurement: 'PV', value: 123 }]) + end + + it 'maps mpp2_power' do + hash = mapper.records_for('senec/0/PV1/MPP_POWER/1', '123.45') + + expect(hash).to eq([{ field: 'mpp2_power', measurement: 'PV', value: 123 }]) + end + + it 'maps mpp3_power' do + hash = mapper.records_for('senec/0/PV1/MPP_POWER/2', '123.45') + + expect(hash).to eq([{ field: 'mpp3_power', measurement: 'PV', value: 123 }]) + end + + it 'maps house_power' do + hash = mapper.records_for('senec/0/ENERGY/GUI_HOUSE_POW', '123.45') + + expect(hash).to eq( + [{ field: 'house_power', measurement: 'PV', value: 123 }], + ) + end + + it 'maps bat_fuel_charge' do + hash = mapper.records_for('senec/0/ENERGY/GUI_BAT_DATA_FUEL_CHARGE', '80.5') + + expect(hash).to eq( + [{ field: 'battery_soc', measurement: 'PV', value: 80.5 }], + ) + end + + it 'maps wallbox_charge_power' do + hash = + mapper.records_for('senec/0/WALLBOX/APPARENT_CHARGING_POWER/0', '123.45') + + expect(hash).to eq( + [{ field: 'wallbox_power0', measurement: 'PV', value: 123 }], + ) + end + + it 'maps wallbox_charge_power1' do + hash = + mapper.records_for('senec/0/WALLBOX/APPARENT_CHARGING_POWER/1', '123.45') + + expect(hash).to eq( + [{ field: 'wallbox_power1', measurement: 'PV', value: 123 }], + ) + end + + it 'maps wallbox_charge_power2' do + hash = + mapper.records_for('senec/0/WALLBOX/APPARENT_CHARGING_POWER/2', '123.45') + + expect(hash).to eq( + [{ field: 'wallbox_power2', measurement: 'PV', value: 123 }], + ) + end + + it 'maps wallbox_charge_power3' do + hash = + mapper.records_for('senec/0/WALLBOX/APPARENT_CHARGING_POWER/3', '123.45') + + expect(hash).to eq( + [{ field: 'wallbox_power3', measurement: 'PV', value: 123 }], + ) + end + + it 'maps battery_charging_power' do + hash = mapper.records_for('senec/0/ENERGY/GUI_BAT_DATA_POWER', '123.45') + + expect(hash).to eq( + [ + { field: 'battery_discharging_power', measurement: 'PV', value: 0.0 }, + { field: 'battery_charging_power', measurement: 'PV', value: 123.45 }, + ], + ) + + expect(hash).to all(include(value: a_kind_of(Float))) + end + + it 'maps bat_power' do + hash = mapper.records_for('senec/0/ENERGY/GUI_BAT_DATA_POWER', '-123.45') + + expect(hash).to eq( + [ + { field: 'battery_discharging_power', measurement: 'PV', value: 123.45 }, + { field: 'battery_charging_power', measurement: 'PV', value: 0.0 }, + ], + ) + + expect(hash).to all(include(value: a_kind_of(Float))) + end + + it 'maps grid_power_plus' do + hash = mapper.records_for('senec/0/ENERGY/GUI_GRID_POW', '123.45') + + expect(hash).to eq( + [ + { field: 'grid_export_power', measurement: 'PV', value: 0 }, + { field: 'grid_import_power', measurement: 'PV', value: 123 }, + ], + ) + + expect(hash).to all(include(value: a_kind_of(Integer))) + end + + it 'maps grid_power_minus' do + hash = mapper.records_for('senec/0/ENERGY/GUI_GRID_POW', '-123.45') + + expect(hash).to eq( + [ + { field: 'grid_export_power', measurement: 'PV', value: 123 }, + { field: 'grid_import_power', measurement: 'PV', value: 0 }, + ], + ) + + expect(hash).to all(include(value: a_kind_of(Integer))) + end + + it 'maps current_state' do + hash = mapper.records_for('senec/0/ENERGY/STAT_STATE_Text', 'LOADING') + + expect(hash).to eq( + [{ field: 'system_status', measurement: 'PV', value: 'LOADING' }], + ) + end + + it 'maps current_state_ok with true' do + %w[true TRUE].each do |value| + hash = mapper.records_for('somewhere/STAT_STATE_OK', value) + + expect(hash).to eq( + [{ field: 'system_status_ok', measurement: 'PV', value: true }], + ) + end + end + + it 'maps current_state_ok with false' do + %w[false FALSE].each do |value| + hash = mapper.records_for('somewhere/STAT_STATE_OK', value) + + expect(hash).to eq( + [{ field: 'system_status_ok', measurement: 'PV', value: false }], + ) + end + end + + it 'maps case_temp' do + hash = mapper.records_for('senec/0/TEMPMEASURE/CASE_TEMP', '35.2') + + expect(hash).to eq([{ field: 'case_temp', measurement: 'PV', value: 35.2 }]) + end + + it 'maps power_ratio' do + hash = mapper.records_for('senec/0/PV1/POWER_RATIO', '0') + + expect(hash).to eq( + [{ field: 'grid_export_limit', measurement: 'PV', value: 0 }], + ) + end + + it 'maps heatpump_power' do + hash = mapper.records_for('somewhere/HEATPUMP/POWER', '123.45') + + expect(hash).to eq( + [{ field: 'power', measurement: 'HEATPUMP', value: 123 }], + ) + end + + it 'maps with JSON_PATH' do + hash = mapper.records_for( + 'go-e/ATTR', + '{"ccp": [103.5098,-9787.971,null,null,10072.18,-180.701,3.295279,100.2145,null,null,null,null,null,null,null,null]}', + ) + + expect(hash).to eq([{ field: 'power', measurement: 'WALLBOX', value: 3.295279 }]) + end + + it 'maps json and calculates formula' do + hash = + mapper.records_for( + 'somewhere/ATTR', + '{"leaving_temp": 35.2, "inlet_temp": 20.5, "water_flow": 16.45}', + ) + + expect(hash).to eq( + [ + { measurement: 'HEATPUMP', field: 'leaving_temp', value: 35.2 }, + { measurement: 'HEATPUMP', field: 'inlet_temp', value: 20.5 }, + { measurement: 'HEATPUMP', field: 'water_flow', value: 16.45 }, + { measurement: 'HEATPUMP', field: 'temp_diff', value: 14.7 }, # 35.2 - 20.5 + { measurement: 'HEATPUMP', field: 'heat', value: 16_874 }, # (16.45 * 60 * 1.163 * (20.5 - 35.2)).round + ], + ) + end + + it 'handles invalid JSON' do + hash = mapper.records_for('somewhere/ATTR', 'this is not JSON') + + expect(hash).to eq([]) + end + + it 'handles invalid value types' do + hash = mapper.records_for('senec/0/ENERGY/GUI_INVERTER_POWER', {}) + expect(hash).to eq([]) + + hash = mapper.records_for('senec/0/ENERGY/GUI_BAT_DATA_FUEL_CHARGE', :foo) + expect(hash).to eq([]) + end + + it 'raises on unknown topic' do + expect do + mapper.records_for('this/is/an/unknown/topic', 'foo!') + end.to raise_error(RuntimeError) + end +end diff --git a/spec/lib/null_logger_spec.rb b/spec/lib/null_logger_spec.rb new file mode 100644 index 0000000..cdde646 --- /dev/null +++ b/spec/lib/null_logger_spec.rb @@ -0,0 +1,19 @@ +require 'null_logger' + +describe NullLogger do + describe '#info' do + subject(:info) { described_class.new.info('message') } + + it 'does nothing' do + expect { info }.not_to raise_error + end + end + + describe '#error' do + subject(:error) { described_class.new.error('message') } + + it 'does nothing' do + expect { error }.not_to raise_error + end + end +end diff --git a/spec/lib/stdout_logger_spec.rb b/spec/lib/stdout_logger_spec.rb new file mode 100644 index 0000000..56b0469 --- /dev/null +++ b/spec/lib/stdout_logger_spec.rb @@ -0,0 +1,34 @@ +require 'stdout_logger' + +describe StdoutLogger do + let(:logger) { described_class.new } + + let(:message) { 'This is the message' } + + describe '#info' do + subject(:info) { logger.info(message) } + + it { expect { info }.to output(/#{message}/).to_stdout } + end + + describe '#error' do + subject(:error) { logger.error(message) } + + it { expect { error }.to output(/#{message}/).to_stdout } + it { expect { error }.to output(/\e\[31m/).to_stdout } + end + + describe '#debug' do + subject(:debug) { logger.debug(message) } + + it { expect { debug }.to output(/#{message}/).to_stdout } + it { expect { debug }.to output(/\e\[34m/).to_stdout } + end + + describe '#warn' do + subject(:warn) { logger.warn(message) } + + it { expect { warn }.to output(/#{message}/).to_stdout } + it { expect { warn }.to output(/\e\[33m/).to_stdout } + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..ccd3753 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,110 @@ +require 'simplecov' +SimpleCov.start + +require 'bundler/setup' +Bundler.require + +require 'dotenv' +Dotenv.load('.env.test.local', '.env.test') + +$LOAD_PATH.unshift(File.expand_path('../app', __dir__)) +Dir[File.join(File.dirname(__FILE__), 'support/**/*.rb')].each { |f| require f } + +require 'webmock/rspec' + +# This file was generated by the `rspec --init` command. Conventionally, all +# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. +# The generated `.rspec` file contains `--require spec_helper` which will cause +# this file to always be loaded, without a need to explicitly require it in any +# files. +# +# Given that it is always loaded, you are encouraged to keep this file as +# light-weight as possible. Requiring heavyweight dependencies from this file +# will add to the boot time of your test suite on EVERY test run, even for an +# individual file that may not need all of that loaded. Instead, consider making +# a separate helper file that requires the additional dependencies and performs +# the additional setup, and require it from the spec files that actually need +# it. +# +# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration +RSpec.configure do |config| + # rspec-expectations config goes here. You can use an alternate + # assertion/expectation library such as wrong or the stdlib/minitest + # assertions if you prefer. + config.expect_with :rspec do |expectations| + # This option will default to `true` in RSpec 4. It makes the `description` + # and `failure_message` of custom matchers include text for helper methods + # defined using `chain`, e.g.: + # be_bigger_than(2).and_smaller_than(4).description + # # => "be bigger than 2 and smaller than 4" + # ...rather than: + # # => "be bigger than 2" + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + # rspec-mocks config goes here. You can use an alternate test double + # library (such as bogus or mocha) by changing the `mock_with` option here. + config.mock_with :rspec do |mocks| + # Prevents you from mocking or stubbing a method that does not exist on + # a real object. This is generally recommended, and will default to + # `true` in RSpec 4. + mocks.verify_partial_doubles = true + end + + # This option will default to `:apply_to_host_groups` in RSpec 4 (and will + # have no way to turn it off -- the option exists only for backwards + # compatibility in RSpec 3). It causes shared context metadata to be + # inherited by the metadata hash of host groups and examples, rather than + # triggering implicit auto-inclusion in groups with matching metadata. + config.shared_context_metadata_behavior = :apply_to_host_groups + + # The settings below are suggested to provide a good initial experience + # with RSpec, but feel free to customize to your heart's content. + # # This allows you to limit a spec run to individual examples or groups + # # you care about by tagging them with `:focus` metadata. When nothing + # # is tagged with `:focus`, all examples get run. RSpec also provides + # # aliases for `it`, `describe`, and `context` that include `:focus` + # # metadata: `fit`, `fdescribe` and `fcontext`, respectively. + # config.filter_run_when_matching :focus + # + # # Allows RSpec to persist some state between runs in order to support + # # the `--only-failures` and `--next-failure` CLI options. We recommend + # # you configure your source control system to ignore this file. + # config.example_status_persistence_file_path = "spec/examples.txt" + # + # # Limits the available syntax to the non-monkey patched syntax that is + # # recommended. For more details, see: + # # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/ + # config.disable_monkey_patching! + # + # # This setting enables warnings. It's recommended, but in some cases may + # # be too noisy due to issues in dependencies. + # config.warnings = true + # + # # Many RSpec users commonly either run the entire suite or an individual + # # file, and it's useful to allow more verbose output when running an + # # individual spec file. + # if config.files_to_run.one? + # # Use the documentation formatter for detailed output, + # # unless a formatter has already been configured + # # (e.g. via a command-line flag). + # config.default_formatter = "doc" + # end + # + # # Print the 10 slowest examples and example groups at the + # # end of the spec run, to help surface which specs are running + # # particularly slow. + # config.profile_examples = 10 + # + # # Run specs in random order to surface order dependencies. If you find an + # # order dependency and want to debug it, you can fix the order by providing + # # the seed, which is printed after each run. + # # --seed 1234 + # config.order = :random + # + # # Seed global randomization in this process using the `--seed` CLI option. + # # Setting this allows you to use `--seed` to deterministically reproduce + # # test failures related to randomization by passing the same `--seed` value + # # as the one that triggered the failure. + # Kernel.srand config.seed +end diff --git a/spec/support/memory_logger.rb b/spec/support/memory_logger.rb new file mode 100644 index 0000000..b3fe1d1 --- /dev/null +++ b/spec/support/memory_logger.rb @@ -0,0 +1,34 @@ +class MemoryLogger + def initialize + @info_messages = [] + @error_messages = [] + @warn_messages = [] + @debug_messages = [] + + @mutex = Mutex.new + end + + attr_reader :info_messages, :error_messages, :warn_messages, :debug_messages + + def info(message) + synchronize { @info_messages << message } + end + + def error(message) + synchronize { @error_messages << message } + end + + def warn(message) + synchronize { @warn_messages << message } + end + + def debug(message) + synchronize { @debug_messages << message } + end + + private + + def synchronize(&) + @mutex.synchronize(&) + end +end diff --git a/test/support/mqtt_fake.rb b/spec/support/mqtt_fake.rb similarity index 96% rename from test/support/mqtt_fake.rb rename to spec/support/mqtt_fake.rb index d65720f..a2299c3 100644 --- a/test/support/mqtt_fake.rb +++ b/spec/support/mqtt_fake.rb @@ -20,7 +20,6 @@ $:.unshift File.dirname(__FILE__) + '/../lib' -require 'logger' require 'socket' require 'mqtt' @@ -31,7 +30,7 @@ class MQTT::FakeServer attr_reader :pings_received attr_accessor :respond_to_pings attr_accessor :just_one_connection - attr_writer :logger + attr_accessor :logger attr_accessor :payload # Create a new fake MQTT server @@ -46,11 +45,6 @@ def initialize(port = nil, bind_address = '127.0.0.1') @respond_to_pings = true end - # Get the logger used by the server - def logger - @logger ||= Logger.new(STDOUT) - end - # Start the thread and open the socket that will process client connections def start(payload_to_publish: 'hello') @payload_to_publish = payload_to_publish @@ -129,5 +123,8 @@ def handle_client(client) rescue MQTT::ProtocolException => e logger.warn "Protocol error, closing connection: #{e}" client.close + rescue Errno::EPIPE, Errno::ECONNRESET + logger.warn "Error, closing connection: #{e}" + client.close end end diff --git a/test/support/vcr_setup.rb b/spec/support/vcr_setup.rb similarity index 73% rename from test/support/vcr_setup.rb rename to spec/support/vcr_setup.rb index 63bb29c..2e86ab8 100644 --- a/test/support/vcr_setup.rb +++ b/spec/support/vcr_setup.rb @@ -1,8 +1,9 @@ require 'vcr' VCR.configure do |config| - config.cassette_library_dir = 'test/cassettes' + config.cassette_library_dir = 'spec/cassettes' config.hook_into :webmock + config.configure_rspec_metadata! record_mode = ENV['VCR'] ? ENV['VCR'].to_sym : :once config.default_cassette_options = { diff --git a/test/config_test.rb b/test/config_test.rb deleted file mode 100644 index 35278c7..0000000 --- a/test/config_test.rb +++ /dev/null @@ -1,109 +0,0 @@ -require 'test_helper' -require 'climate_control' - -class ConfigTest < Minitest::Test - VALID_ENV = { - MQTT_HOST: '1.2.3.4', - MQTT_PORT: '1883', - MQTT_USERNAME: 'username', - MQTT_PASSWORD: 'password', - MQTT_SSL: 'false', - # --- - INFLUX_HOST: 'influx.example.com', - INFLUX_SCHEMA: 'https', - INFLUX_PORT: '443', - INFLUX_TOKEN: 'this.is.just.an.example', - INFLUX_ORG: 'solectrus', - INFLUX_BUCKET: 'my-bucket', - # --- - MQTT_TOPIC_HOUSE_POW: 'senec/0/ENERGY/GUI_HOUSE_POW', - MQTT_TOPIC_GRID_POW: 'senec/0/ENERGY/GUI_GRID_POW', - MQTT_TOPIC_BAT_CHARGE_CURRENT: 'senec/0/ENERGY/GUI_BAT_DATA_CURRENT', - MQTT_TOPIC_BAT_FUEL_CHARGE: 'senec/0/ENERGY/GUI_BAT_DATA_FUEL_CHARGE', - MQTT_TOPIC_BAT_POWER: 'senec/0/ENERGY/GUI_BAT_DATA_POWER', - MQTT_TOPIC_BAT_VOLTAGE: 'senec/0/ENERGY/GUI_BAT_DATA_VOLTAGE', - MQTT_TOPIC_CASE_TEMP: 'senec/0/TEMPMEASURE/CASE_TEMP', - MQTT_TOPIC_CURRENT_STATE: 'senec/0/ENERGY/STAT_STATE_Text', - MQTT_TOPIC_MPP1_POWER: 'senec/0/PV1/MPP_POWER/0', - MQTT_TOPIC_MPP2_POWER: 'senec/0/PV1/MPP_POWER/1', - MQTT_TOPIC_MPP3_POWER: 'senec/0/PV1/MPP_POWER/2', - MQTT_TOPIC_INVERTER_POWER: 'senec/0/ENERGY/GUI_INVERTER_POWER', - MQTT_TOPIC_WALLBOX_CHARGE_POWER: - 'senec/0/WALLBOX/APPARENT_CHARGING_POWER/0', - }.freeze - - def config - @config ||= ClimateControl.modify(VALID_ENV) { Config.from_env } - end - - def test_valid_options - assert(config) - end - - def test_mqtt_credentials - VALID_ENV - .slice(:MQTT_HOST, :MQTT_PORT, :MQTT_USERNAME, :MQTT_PASSWORD) - .each { |key, value| assert_equal value, config.send(key.downcase) } - - assert_equal 'mqtt://1.2.3.4:1883', config.mqtt_url - refute config.mqtt_ssl - end - - def test_mqtt_topics - VALID_ENV - .slice( - :MQTT_TOPIC_HOUSE_POW, - :MQTT_TOPIC_GRID_POW, - :MQTT_TOPIC_BAT_CHARGE_CURRENT, - :MQTT_TOPIC_BAT_FUEL_CHARGE, - :MQTT_TOPIC_BAT_POWER, - :MQTT_TOPIC_BAT_VOLTAGE, - :MQTT_TOPIC_CASE_TEMP, - :MQTT_TOPIC_CURRENT_STATE, - :MQTT_TOPIC_MPP1_POWER, - :MQTT_TOPIC_MPP2_POWER, - :MQTT_TOPIC_MPP3_POWER, - :MQTT_TOPIC_INVERTER_POWER, - :MQTT_TOPIC_WALLBOX_CHARGE_POWER, - ) - .each { |key, value| assert_equal value, config.send(key.downcase) } - end - - def test_mqtt_flip_bat_power_true - config_flip = - ClimateControl.modify(VALID_ENV.merge(MQTT_FLIP_BAT_POWER: 'true')) do - Config.from_env - end - - assert config_flip.mqtt_flip_bat_power - end - - def test_mqtt_flip_bat_power_false - config_flip = - ClimateControl.modify(VALID_ENV.merge(MQTT_FLIP_BAT_POWER: 'false')) do - Config.from_env - end - - refute config_flip.mqtt_flip_bat_power - end - - def test_influx_methods - VALID_ENV - .slice( - :INFLUX_HOST, - :INFLUX_SCHEMA, - :INFLUX_PORT, - :INFLUX_TOKEN, - :INFLUX_ORG, - :INFLUX_BUCKET, - ) - .each { |key, value| assert_equal value, config.send(key.downcase) } - - assert_equal 'https://influx.example.com:443', config.influx_url - end - - def test_invalid_options - assert_raises(Exception) { Config.new({}) } - assert_raises(Exception) { Config.new(influx_host: 'this is no host') } - end -end diff --git a/test/loop_test.rb b/test/loop_test.rb deleted file mode 100644 index 8bc3c1d..0000000 --- a/test/loop_test.rb +++ /dev/null @@ -1,31 +0,0 @@ -require 'test_helper' -require 'loop' - -class LoopTest < Minitest::Test - def mqtt_server - @mqtt_server ||= - begin - server = MQTT::FakeServer.new(nil, '127.0.01') - server.just_one_connection = true - server.logger.level = Logger::WARN - server - end - end - - def config - @config ||= - Config.from_env mqtt_host: mqtt_server.address, - mqtt_port: mqtt_server.port - end - - def test_start - mqtt_server.start(payload_to_publish: '80.0') - - out, _err = - capture_io do - VCR.use_cassette('influx_success') { Loop.start(config:, max_count: 1) } - end - - assert_match(/{"bat_fuel_charge"=>80/, out) - end -end diff --git a/test/mapper_test.rb b/test/mapper_test.rb deleted file mode 100644 index 453e54f..0000000 --- a/test/mapper_test.rb +++ /dev/null @@ -1,158 +0,0 @@ -require 'test_helper' -require 'mapper' -require 'config' - -VALID_ENV = { - MQTT_HOST: '1.2.3.4', - MQTT_PORT: '1883', - MQTT_USERNAME: 'username', - MQTT_PASSWORD: 'password', - MQTT_SSL: 'false', - # --- - INFLUX_HOST: 'influx.example.com', - INFLUX_SCHEMA: 'https', - INFLUX_PORT: '443', - INFLUX_TOKEN: 'this.is.just.an.example', - INFLUX_ORG: 'solectrus', - INFLUX_BUCKET: 'my-bucket', - # --- - MQTT_TOPIC_HOUSE_POW: 'senec/0/ENERGY/GUI_HOUSE_POW', - MQTT_TOPIC_GRID_POW: 'senec/0/ENERGY/GUI_GRID_POW', - MQTT_TOPIC_BAT_CHARGE_CURRENT: 'senec/0/ENERGY/GUI_BAT_DATA_CURRENT', - MQTT_TOPIC_BAT_FUEL_CHARGE: 'senec/0/ENERGY/GUI_BAT_DATA_FUEL_CHARGE', - MQTT_TOPIC_BAT_POWER: 'senec/0/ENERGY/GUI_BAT_DATA_POWER', - MQTT_TOPIC_BAT_VOLTAGE: 'senec/0/ENERGY/GUI_BAT_DATA_VOLTAGE', - MQTT_TOPIC_CASE_TEMP: 'senec/0/TEMPMEASURE/CASE_TEMP', - MQTT_TOPIC_CURRENT_STATE: 'senec/0/ENERGY/STAT_STATE_Text', - MQTT_TOPIC_MPP1_POWER: 'senec/0/PV1/MPP_POWER/0', - MQTT_TOPIC_MPP2_POWER: 'senec/0/PV1/MPP_POWER/1', - MQTT_TOPIC_MPP3_POWER: 'senec/0/PV1/MPP_POWER/2', - MQTT_TOPIC_INVERTER_POWER: 'senec/0/ENERGY/GUI_INVERTER_POWER', - MQTT_TOPIC_WALLBOX_CHARGE_POWER: 'senec/0/WALLBOX/APPARENT_CHARGING_POWER/0', -}.freeze - -class MapperTest < Minitest::Test - def default_config - @default_config ||= ClimateControl.modify(VALID_ENV) { Config.from_env } - end - - def mapper(config: nil) - Mapper.new(config: config || default_config) - end - - def test_topics - assert_equal %w[ - senec/0/ENERGY/GUI_BAT_DATA_FUEL_CHARGE - senec/0/ENERGY/GUI_BAT_DATA_POWER - senec/0/ENERGY/GUI_GRID_POW - senec/0/ENERGY/GUI_HOUSE_POW - senec/0/ENERGY/GUI_INVERTER_POWER - senec/0/ENERGY/STAT_STATE_Text - senec/0/PV1/MPP_POWER/0 - senec/0/PV1/MPP_POWER/1 - senec/0/PV1/MPP_POWER/2 - senec/0/TEMPMEASURE/CASE_TEMP - senec/0/WALLBOX/APPARENT_CHARGING_POWER/0 - ], - mapper.topics - end - - def test_call_with_inverter_power - hash = mapper.call('senec/0/ENERGY/GUI_INVERTER_POWER', '123.45') - - assert_equal({ 'inverter_power' => 123 }, hash) - end - - def test_call_with_mpp1_power - hash = mapper.call('senec/0/PV1/MPP_POWER/0', '123.45') - - assert_equal({ 'mpp1_power' => 123 }, hash) - end - - def test_call_with_mpp2_power - hash = mapper.call('senec/0/PV1/MPP_POWER/1', '123.45') - - assert_equal({ 'mpp2_power' => 123 }, hash) - end - - def test_call_with_mpp3_power - hash = mapper.call('senec/0/PV1/MPP_POWER/2', '123.45') - - assert_equal({ 'mpp3_power' => 123 }, hash) - end - - def test_call_with_house_pow - hash = mapper.call('senec/0/ENERGY/GUI_HOUSE_POW', '123.45') - - assert_equal({ 'house_power' => 123 }, hash) - end - - def test_call_with_bat_fuel_charge - hash = mapper.call('senec/0/ENERGY/GUI_BAT_DATA_FUEL_CHARGE', '123.45') - - assert_equal({ 'bat_fuel_charge' => 123.5 }, hash) - end - - def test_call_with_wallbox_charge_power - hash = mapper.call('senec/0/WALLBOX/APPARENT_CHARGING_POWER/0', '123.45') - - assert_equal({ 'wallbox_charge_power' => 123 }, hash) - end - - def test_call_with_bat_power_plus - hash = mapper.call('senec/0/ENERGY/GUI_BAT_DATA_POWER', '123.45') - - assert_equal({ 'bat_power_plus' => 123, 'bat_power_minus' => 0 }, hash) - end - - def test_call_with_bat_power_minus - hash = mapper.call('senec/0/ENERGY/GUI_BAT_DATA_POWER', '-123.45') - - assert_equal({ 'bat_power_plus' => 0, 'bat_power_minus' => 123 }, hash) - end - - def test_call_with_bat_power_minus_with_flip - other_config = - ClimateControl.modify(VALID_ENV.merge(MQTT_FLIP_BAT_POWER: 'true')) do - Config.from_env - end - - hash = - mapper(config: other_config).call( - 'senec/0/ENERGY/GUI_BAT_DATA_POWER', - '-123.45', - ) - - assert_equal({ 'bat_power_plus' => 123, 'bat_power_minus' => 0 }, hash) - end - - def test_call_with_grid_power_plus - hash = mapper.call('senec/0/ENERGY/GUI_GRID_POW', '123.45') - - assert_equal({ 'grid_power_plus' => 123, 'grid_power_minus' => 0 }, hash) - end - - def test_call_with_grid_power_minus - hash = mapper.call('senec/0/ENERGY/GUI_GRID_POW', '-123.45') - - assert_equal({ 'grid_power_plus' => 0, 'grid_power_minus' => 123 }, hash) - end - - def test_call_with_current_state - hash = mapper.call('senec/0/ENERGY/STAT_STATE_Text', 'LOADING') - - assert_equal({ 'current_state' => 'LOADING' }, hash) - end - - def test_call_with_case_temp - hash = mapper.call('senec/0/TEMPMEASURE/CASE_TEMP', '35.2') - - assert_equal({ 'case_temp' => 35.2 }, hash) - end - - def test_call_with_unknown_topic - assert_raises RuntimeError do - mapper.call('this/is/an/unknown/topic', 'foo!') - end - end -end diff --git a/test/test_helper.rb b/test/test_helper.rb deleted file mode 100644 index 4d76bf8..0000000 --- a/test/test_helper.rb +++ /dev/null @@ -1,10 +0,0 @@ -require 'simplecov' # These two lines must go first -SimpleCov.start - -$LOAD_PATH.unshift(File.expand_path('../app', __dir__)) - -require 'minitest/autorun' -require 'webmock/minitest' - -# Support files -Dir["#{__dir__}/support/*.rb"].each { |file| require file }