~sirn/aquacomputer-mqtt-exporter

981770a8fd5e4bdf122b275109f97351a843c416 — Sirn Thanabulpong 6 months ago v0.1.0
Initial version
A  => .builds/build.yml +64 -0
@@ 1,64 @@
---
image: alpine/latest
packages:
  - podman
sources:
  - https://git.sr.ht/~sirn/aquacomputer-mqtt-exporter
secrets:
  - 214857e3-03b1-4fe7-b66d-a66e7a8a7c28
environment:
  publish_ref: refs/heads/main
  publish_repo: https://git.sr.ht/~sirn/aquacomputer-mqtt-exporter
  image_name: docker.io/sirn/aquacomputer-mqtt-exporter
tasks:
  - setup: |
      sudo rc-update add cgroups
      sudo rc-service cgroups start
      sudo modprobe tun
      echo tun | sudo tee -a /etc/modules
      echo $USER:100000:65536 | sudo tee -a /etc/subuid
      echo $USER:100000:65536 | sudo tee -a /etc/subgid
  - build: |
      cd aquacomputer-mqtt-exporter/
      podman build . --network=host -t "${image_name}":dev
  - push: |
      cd aquacomputer-mqtt-exporter/
      podman login --authfile $HOME/.docker/config.json docker.io

      version=$(sh ./scripts/gen-version.sh)
      version=${version#v}

      # Publish tagged release v1.0.0 as `latest`, `1', `1.0', and `1.0.0'
      # and untagged release v1.0.0-dev-123 as only `1.0.0-dev-123'.
      if [ -n "$(git tag --points-at HEAD)" ]; then
        while true; do
          case "${version}" in
            *-* )
              image_tag="${version} ${image_tag}"
              version="${version%-*}"
              ;;
            *.* )
              image_tag="${version} ${image_tag}"
              version="${version%.*}"
              ;;
            0 )
              # Tagging 0 is weird, special-casing here to skip.
              # It's much more useful to refer to e.g. `0.1' in this case.
              break
              ;;
            * )
              image_tag="${version} ${image_tag}"
              break
              ;;
          esac
        done

        image_tag="latest ${image_tag}"
      else
        image_tag="${version}"
      fi

      for tag in ${image_tag}; do
        podman tag "${image_name}":dev "${image_name}":"${tag}"
        podman push "${image_name}":"${tag}"
      done

A  => .gitignore +15 -0
@@ 1,15 @@
# OS-specific
Thumbs.db
.DS_Store

# Editor specific
.vscode
.idea

# Project specific
!/**/.gitkeep
/**/bin
/tmp
.envrc
config.toml
go.mod.local

A  => Containerfile +24 -0
@@ 1,24 @@
# Stage 1: App
# -----------------------------------------------------------------------------
FROM golang:1.21

WORKDIR /app
COPY go.mod go.sum ./
RUN set -xe \
 && go mod download \
 && go mod verify

COPY . ./
RUN set -xe \
 && make \
 && mv bin/aquacomputer-mqtt-exporter-* bin/aquacomputer-mqtt-exporter

# Stage 2: Final
# -----------------------------------------------------------------------------
FROM gcr.io/distroless/static-debian12

WORKDIR /
COPY --from=0 /app/bin/aquacomputer-mqtt-exporter /

EXPOSE 9110
CMD ["/aquacomputer-mqtt-exporter", "--config", "/config.toml"]

A  => GNUmakefile +116 -0
@@ 1,116 @@
DIST?= $(shell uname -m | tr '[[:upper:]]' '[[:lower:]]')-$(shell uname | tr '[[:upper:]]' '[[:lower:]]')
SHELL?= /usr/bin/env bash
WATCHEXEC?= watchexec

GO?= go
GOLANGCILINT?= golangci-lint
GOTEST?= $(GO) test
GOTEST_ARGS?= -v
GO_ARGS?=
TEST_ENV?= test

GO_PKGS= $(shell $(GO) list ./... | xargs -L 1)
GO_SRCS= $(shell find $(PWD) -iname "*.go")
DIST_BIN= bin/aquacomputer-mqtt-exporter-$(DIST)
VERSION?= $(shell sh scripts/gen-version.sh)


## Meta
##

all: $(DIST_BIN)

.PHONY: help
help:
	@: # Print this help
	@printf 'Usage: make TARGET\n\n'
	@printf 'You must run make with one of the following targets to continue:\n'
	@printf 'Run make VERBOSE=1 TARGET for verbose log.\n'
	@awk ' \
		/^[^\t:]+:/ { \
			split($$0, s, ":"); \
			cmd=s[1]; \
		} /^\t@: #/ { \
			split($$0, t, ": # "); \
			body=t[2]; \
			doprnt=1; \
		} { \
			if (doprnt == 1) { \
				if (cmd != "") { \
					printf("\n\033[33m%s\033[0m:\n", cmd); \
					cmd="" \
				}; \
				printf("\t%s\n", body); \
				doprnt=0; \
				body=""; \
			}; \
		} \
	' GNUmakefile


## Phony Targets
##

.PHONY: benchmark
benchmark:
	@: # Run benchmark
	exitCode=0; \
	for n in $(GO_PKGS); do \
		if ! $(GOTEST) $(GOTEST_ARGS) $(ARGS) $$n -bench . -benchmem; then \
			exitCode=1; \
		fi; \
	done; \
	exit $$exitCode

.PHONY: test
test:
	@: # Run tests
	exitCode=0; \
	for n in $(GO_PKGS); do \
		if ! env GO_ENV=$(TEST_ENV) $(GOTEST) $(GOTEST_ARGS) $(ARGS) $$n; then \
			exitCode=1; \
		fi; \
	done; \
	exit $$exitCode

.PHONY: lint
lint: golangci-lint

.PHONY: golangci-lint
golangci-lint:
	@: # Run golangci-lint
	$(GOLANGCILINT) run $(GOLANGLINT_ARGS) $(ARGS)

.PHONY: watch
watch:
	@: # Watch for changes and restart the server
	@: # Requires watchexec to be installed.
	watchexec -r -- sh -c "make && $(DIST_BIN)"

## Targets
##

## SECONDEXPANSION targets
## These targets are expanded twice during read phase, so we can e.g.
## use a stem (%) in wildcard.
##

.SECONDEXPANSION:
bin/%-$(DIST): $(GO_SRCS) $$(shell find $$(subst -$(DIST),,$$(subst bin/,cmd/,$$@)) -type f)
	@: # Build binary bin/TARGET with all .go files in cmd/TARGET/
	@: # as its source file.
	@echo >&2 "Building $@..."
	mkdir -p "$(@D)"
	env CGO_ENABLED=0 \
		$(GO) build \
			$(GO_ARGS) \
			$(ARGS) \
			-ldflags "-X main.Version=$(VERSION) -extldflags=-static" \
			-o "$@" \
			"$(wildcard $(subst -$(DIST),,$(subst bin/,cmd/,$@)/main.go))"


## Special targets
##

$(VERBOSE).SILENT:

A  => LICENSE +29 -0
@@ 1,29 @@
BSD 3-Clause License

Copyright (c) 2023, Sirn Thanabulpong
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

* Redistributions of source code must retain the above copyright notice, this
  list of conditions and the following disclaimer.

* Redistributions in binary form must reproduce the above copyright notice,
  this list of conditions and the following disclaimer in the documentation
  and/or other materials provided with the distribution.

* Neither the name of the copyright holder nor the names of its
  contributors may be used to endorse or promote products derived from
  this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
\ No newline at end of file

A  => README.md +83 -0
@@ 1,83 @@
# Aquacomputer MQTT Exporter

Export Aquacomputer MQTT payload as Prometheus metrics.

## Usage

```shell
$ podman run -p 9110:9110 -v "./config.toml:/config.toml" --rm -it docker.io/sirn/aquacomputer-mqtt-exporter
```

For configuration options, see [config.sample.toml](./config.sample.toml).

Configure Prometheus to scrape from this endpoint:

```yaml
scrape_configs:
  - job_name: "aquacomputer"
    scrape_interval: 15s
    static_configs:
      - targets: ["localhost:9110"]
```

## Example

This program turns this message:

```json
{
  "Id": "47f11ea4-ce83-42f0-950a-e980ebdc47bc",
  "Topic": "aquacomputer/temp_celsius/terra",
  "Title": "temp_celsius",
  "Message": "",
  "Data": [
    {
      "Name": "Intake Temp.",
      "Id": "data\\temperatures\\0",
      "Unit": "°C",
      "Value": 23.65
    },
    {
      "Name": "Case Temp.",
      "Id": "data\\temperatures\\1",
      "Unit": "°C",
      "Value": 35.05
    },
    {
      "Name": "Delta Temp.",
      "Id": "data\\temperatures\\24",
      "Unit": "°C",
      "Value": 10.31
    },
    {
      "Name": "Coolant Avg. Temp.",
      "Id": "data\\temperatures\\26",
      "Unit": "°C",
      "Value": 33.96
    },
    {
      "Name": "Highflow Temp.",
      "Id": "data\\temperatures\\29",
      "Unit": "°C",
      "Value": 34.7
    },
    {
      "Name": "Pump Temp.",
      "Id": "data\\temperatures\\36",
      "Unit": "°C",
      "Value": 33.23
    }
  ]
}
```

Into:

```
aquacomputer_temp_celsius{device="terra",name="Case Temp.",sensor="data\\temperatures\\1"} 35.05
aquacomputer_temp_celsius{device="terra",name="Coolant Avg. Temp.",sensor="data\\temperatures\\26"} 33.96
aquacomputer_temp_celsius{device="terra",name="Delta Temp.",sensor="data\\temperatures\\24"} 10.31
aquacomputer_temp_celsius{device="terra",name="Highflow Temp.",sensor="data\\temperatures\\29"} 34.7
aquacomputer_temp_celsius{device="terra",name="Intake Temp.",sensor="data\\temperatures\\0"} 23.65
aquacomputer_temp_celsius{device="terra",name="Pump Temp.",sensor="data\\temperatures\\36"} 33.23
```

A  => cmd/aquacomputer-mqtt-exporter/main.go +78 -0
@@ 1,78 @@
package main

import (
	"fmt"
	"log"

	"git.sr.ht/~sirn/aquacomputer-mqtt-exporter/internal/aquacomputer"
	"git.sr.ht/~sirn/aquacomputer-mqtt-exporter/internal/config"
	"git.sr.ht/~sirn/aquacomputer-mqtt-exporter/internal/mqttclient"
	"git.sr.ht/~sirn/aquacomputer-mqtt-exporter/internal/server"
	"git.sr.ht/~sirn/aquacomputer-mqtt-exporter/pkg/sgracer"
	"git.sr.ht/~sirn/aquacomputer-mqtt-exporter/pkg/slogger"
	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/collectors"
	"github.com/spf13/cobra"
)

// Version specifies the current version. This variable should be set
// on compile time via `-X main.Version=VERSION` during compilation.
var Version = "undefined"

var configPath string

var rootCmd = &cobra.Command{
	Use:   "aquacomputer-mqtt-exporter",
	Short: "Export Aquacomputer MQTT payload to Prometheus",
	Run: func(cmd *cobra.Command, args []string) {
		ctx := cmd.Context()
		config, err := config.NewConfig(configPath)
		if err != nil {
			slogger.Fatalf("could not load configuration: %s", err)
		}

		logger, err := slogger.NewBasicLogger(config.Logger.Severity, config.Logger.Format)
		if err != nil {
			slogger.Fatalf("could not initialize logger: %s", err)
		}

		mqttclient, err := mqttclient.NewMQTTClient(config, logger)
		if err != nil {
			slogger.Fatalf("could not connect to mqtt broker: %s", err)
		}
		logger.Infof("connected to mqtt broker at %s", mqttclient.GetAddress())

		registry := prometheus.NewRegistry()
		registry.MustRegister(collectors.NewBuildInfoCollector())
		registry.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))
		registry.MustRegister(collectors.NewGoCollector())
		registry.MustRegister(aquacomputer.NewCollector(config, logger, mqttclient))

		server := server.NewServer(config, logger, registry)
		logger.Infof("metrics server listened at %s", server.GetAddress())
		sgracer.GracefulStart(ctx, server)
	},
}

var versionCmd = &cobra.Command{
	Use:   "version",
	Short: "Print the current version",
	Run: func(cmd *cobra.Command, args []string) {
		fmt.Println(Version)
	},
}

func main() {
	rootCmd.PersistentFlags().StringVarP(
		&configPath,
		"config", "f",
		"config.toml",
		"path to configuration file",
	)

	rootCmd.AddCommand(versionCmd)

	if err := rootCmd.Execute(); err != nil {
		log.Fatal(err)
	}
}

A  => config.sample.toml +61 -0
@@ 1,61 @@
## Aquacomputer MQTT Exporter Configuration
##
## All configurations can also be provided as an environment variable
## by joining the section name and the configuration name with an underscore
## and converting it to upper case.
##
## For example, the configuration "logger.severity" can be provided as an
## environment variable "LOGGER_SEVERITY".
##

# =========================================================================
# Configures the logger
# =========================================================================
[logger]
# Sets the log level. One of: "info", "warning", "error", "debug"
severity = "info"

# Sets the output of the log. One of: "console", "json"
format = "json"

# =========================================================================
# Configures the MQTT broker
# =========================================================================
[mqtt]
# Sets the MQTT broker address.
broker = "tcp://127.0.0.1:1883"

# Sets the username for MQTT broker.
username = "username"

# Sets the password for MQTT broker.
password = "password"

# Topic containing Aquacomputer payload. Use # to match any level, or
# use + to match one level. For example "aquacomputer/#" or
# "aquacomputer/+/+".
topic = "aquacomputer/#"

# =========================================================================
# Configures the Prometheus metrics
# =========================================================================
[metric]
# A regular expression for extracting device name and metric name
# from the topic. Both name and device are required to be present.
#
# For example, given "aquacomputer/temp/pc1" as a topic name
# the default regexp will extract it as prefix_temp{device="pc1", ...}
regexp = "aquacomputer/(?P<name>[^/]+)/(?P<device>[^/]+)"

# Sets a prefix for a metric.
prefix = "aquacomputer"

# =========================================================================
# Configures the HTTP server
# =========================================================================
[http]
# Sets the listen address for the HTTP metrics server.
listen = "0.0.0.0"

# Sets the listen port for the HTTP metrics server.
port = "9110"

A  => flake.lock +61 -0
@@ 1,61 @@
{
  "nodes": {
    "flake-utils": {
      "inputs": {
        "systems": "systems"
      },
      "locked": {
        "lastModified": 1701680307,
        "narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
        "owner": "numtide",
        "repo": "flake-utils",
        "rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
        "type": "github"
      },
      "original": {
        "owner": "numtide",
        "repo": "flake-utils",
        "type": "github"
      }
    },
    "nixpkgs": {
      "locked": {
        "lastModified": 1702233072,
        "narHash": "sha256-H5G2wgbim2Ku6G6w+NSaQaauv6B6DlPhY9fMvArKqRo=",
        "owner": "nixos",
        "repo": "nixpkgs",
        "rev": "781e2a9797ecf0f146e81425c822dca69fe4a348",
        "type": "github"
      },
      "original": {
        "owner": "nixos",
        "ref": "nixos-23.11",
        "repo": "nixpkgs",
        "type": "github"
      }
    },
    "root": {
      "inputs": {
        "flake-utils": "flake-utils",
        "nixpkgs": "nixpkgs"
      }
    },
    "systems": {
      "locked": {
        "lastModified": 1681028828,
        "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
        "owner": "nix-systems",
        "repo": "default",
        "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
        "type": "github"
      },
      "original": {
        "owner": "nix-systems",
        "repo": "default",
        "type": "github"
      }
    }
  },
  "root": "root",
  "version": 7
}

A  => flake.nix +42 -0
@@ 1,42 @@
{
  description = "A flake environment for Aquacomputer MQTT exporter";

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-23.11";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = { self, nixpkgs, flake-utils }:
    flake-utils.lib.eachDefaultSystem (system:
      let
        overlayPkgs =
          (final: prev:
            rec { });

        pkgs = import nixpkgs {
          inherit system;
          overlays = [ overlayPkgs ];
        };
      in
      rec {
        devShell = with pkgs; mkShell {
          name = "aquacomputer-mqtt-dev";

          packages = [
            git
            gnumake
            go
            golangci-lint
            gopls
            podman
            watchexec
          ];

          shellHook = ''
            export GO=${go}/bin/go
            export WATCHEXEC=${watchexec}/bin/watchexec
          '';
        };
      }
    );
}

A  => go.mod +45 -0
@@ 1,45 @@
module git.sr.ht/~sirn/aquacomputer-mqtt-exporter

go 1.21.4

require (
	github.com/eclipse/paho.mqtt.golang v1.4.3
	github.com/prometheus/client_golang v1.17.0
	github.com/spf13/cobra v1.8.0
	github.com/spf13/viper v1.18.1
	go.uber.org/zap v1.21.0
)

require (
	github.com/beorn7/perks v1.0.1 // indirect
	github.com/cespare/xxhash/v2 v2.2.0 // indirect
	github.com/fsnotify/fsnotify v1.7.0 // indirect
	github.com/golang/protobuf v1.5.3 // indirect
	github.com/gorilla/websocket v1.5.0 // indirect
	github.com/hashicorp/hcl v1.0.0 // indirect
	github.com/inconshreveable/mousetrap v1.1.0 // indirect
	github.com/magiconair/properties v1.8.7 // indirect
	github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
	github.com/mitchellh/mapstructure v1.5.0 // indirect
	github.com/pelletier/go-toml/v2 v2.1.0 // indirect
	github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 // indirect
	github.com/prometheus/common v0.44.0 // indirect
	github.com/prometheus/procfs v0.11.1 // indirect
	github.com/sagikazarmark/locafero v0.4.0 // indirect
	github.com/sagikazarmark/slog-shim v0.1.0 // indirect
	github.com/sourcegraph/conc v0.3.0 // indirect
	github.com/spf13/afero v1.11.0 // indirect
	github.com/spf13/cast v1.6.0 // indirect
	github.com/spf13/pflag v1.0.5 // indirect
	github.com/subosito/gotenv v1.6.0 // indirect
	go.uber.org/atomic v1.9.0 // indirect
	go.uber.org/multierr v1.9.0 // indirect
	golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
	golang.org/x/net v0.19.0 // indirect
	golang.org/x/sync v0.5.0 // indirect
	golang.org/x/sys v0.15.0 // indirect
	golang.org/x/text v0.14.0 // indirect
	google.golang.org/protobuf v1.31.0 // indirect
	gopkg.in/ini.v1 v1.67.0 // indirect
	gopkg.in/yaml.v3 v3.0.1 // indirect
)

A  => go.sum +154 -0
@@ 1,154 @@
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/eclipse/paho.mqtt.golang v1.4.3 h1:2kwcUGn8seMUfWndX0hGbvH8r7crgcJguQNCyp70xik=
github.com/eclipse/paho.mqtt.golang v1.4.3/go.mod h1:CSYvoAlsMkhYOXh/oKyxa8EcBci6dVkLCbo5tTC1RIE=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 h1:v7DLqVdK4VrYkVD5diGdl4sxJurKJEMnODWRJlxV9oM=
github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU=
github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY=
github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY=
github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI=
github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.18.1 h1:rmuU42rScKWlhhJDyXZRKJQHXFX02chSVW1IvkPGiVM=
github.com/spf13/viper v1.18.1/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
go.uber.org/zap v1.21.0 h1:WefMeulhovoZ2sYXz7st6K0sLj7bBhpiFaud4r4zST8=
go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

A  => internal/aquacomputer/aquacomputer.go +17 -0
@@ 1,17 @@
package aquacomputer

// AquacomputerData represents Aquacomputer's MQTT data payload.
type AquacomputerData struct {
	Id    string  `json:"Id"`
	Name  string  `json:"Name"`
	Value float64 `json:"Value"`
}

// AquacomputerPayload represents Aquacomputer's MQTT payload.
type AquacomputerPayload struct {
	Id      string              `json:"Id"`
	Topic   string              `json:"Topic"`
	Title   string              `json:"Title"`
	Message string              `json:"Message"`
	Data    []*AquacomputerData `json:"Data"`
}

A  => internal/aquacomputer/collector.go +104 -0
@@ 1,104 @@
package aquacomputer

import (
	"encoding/json"
	"fmt"
	"regexp"

	"git.sr.ht/~sirn/aquacomputer-mqtt-exporter/internal/config"
	"git.sr.ht/~sirn/aquacomputer-mqtt-exporter/internal/mqttclient"
	"git.sr.ht/~sirn/aquacomputer-mqtt-exporter/pkg/slogger"
	mqtt "github.com/eclipse/paho.mqtt.golang"
	"github.com/prometheus/client_golang/prometheus"
)

// AquacomputerCollector implements prometheus.Collector.
type AquacomputerCollector struct {
	config     *config.Config
	logger     slogger.Logger
	metrics    map[string]prometheus.Gauge
	mqttclient *mqttclient.MQTTClient
}

// Describe implements collector.
func (c *AquacomputerCollector) Describe(ch chan<- *prometheus.Desc) {
	for _, m := range c.metrics {
		ch <- m.Desc()
	}
}

// Collect implements collector.
func (c *AquacomputerCollector) Collect(ch chan<- prometheus.Metric) {
	for _, m := range c.metrics {
		ch <- m
	}
}

// GenerateName generates a new name with prefix.
func (c *AquacomputerCollector) GenerateName(name string) string {
	return fmt.Sprintf("%s_%s", c.config.Metric.Prefix, name)
}

// NewCollectorFunc returns a collector function.
func (c *AquacomputerCollector) NewCollectorFunc() func(mqtt.Client, mqtt.Message) {
	extractor := regexp.MustCompile(c.config.Metric.Regexp)
	groupNames := extractor.SubexpNames()

	return func(client mqtt.Client, message mqtt.Message) {
		var payload *AquacomputerPayload

		rawPayload := message.Payload()
		c.logger.Debugw("received payload", "payload", string(rawPayload))
		if err := json.Unmarshal(rawPayload, &payload); err != nil {
			c.logger.Errorw("cannot unmarshal payload", "error", err)
			return
		}

		matched := make(map[string]string)
		for i, match := range extractor.FindStringSubmatch(payload.Topic) {
			matched[groupNames[i]] = match
		}

		if _, ok := matched["name"]; !ok {
			c.logger.Errorw("could not extract name from topic", "topic", payload.Topic)
			return
		}

		if _, ok := matched["device"]; !ok {
			c.logger.Errorw("could not extract device from topic", "topic", payload.Topic)
			return
		}

		for _, d := range payload.Data {
			comp := fmt.Sprintf("%s_%s_%s", payload.Id, payload.Topic, d.Id)
			if _, ok := c.metrics[comp]; !ok {
				c.metrics[comp] = prometheus.NewGauge(prometheus.GaugeOpts{
					Name: c.GenerateName(matched["name"]),
					ConstLabels: prometheus.Labels{
						"device": matched["device"],
						"name":   d.Name,
						"sensor": d.Id,
					},
				})
			}

			c.metrics[comp].Set(d.Value)
		}
	}
}

// NewCollector returns an instance of AquacomputerCollector.
func NewCollector(config *config.Config, logger slogger.Logger, mqttclient *mqttclient.MQTTClient) prometheus.Collector {
	ac := &AquacomputerCollector{
		config:     config,
		logger:     logger,
		metrics:    make(map[string]prometheus.Gauge),
		mqttclient: mqttclient,
	}

	if err := mqttclient.Subscribe(config.MQTT.Topic, ac.NewCollectorFunc()); err != nil {
		logger.Fatalf("could not subscribe to topic: %s", err)
	}

	return ac
}

A  => internal/config/config.go +90 -0
@@ 1,90 @@
package config

import (
	"io/fs"
	"strings"

	"github.com/spf13/viper"
)

// LoggerConfig represents logger configuration.
type LoggerConfig struct {
	Severity string
	Format   string
}

// MQTTConfig represents MQTT configuration.
type MQTTConfig struct {
	Broker   string
	Username string
	Password string
	Topic    string
}

// HTTPConfig represents HTTP configuration.
type HTTPConfig struct {
	Listen string
	Port   string
}

// MetricConfig represents metric configuration.
type MetricConfig struct {
	Regexp string
	Prefix string
}

// Config represents application configuration.
type Config struct {
	Logger LoggerConfig `mapstructure:"logger"`
	Metric MetricConfig `mapstructure:"metric"`
	MQTT   MQTTConfig   `mapstructure:"mqtt"`
	HTTP   HTTPConfig   `mapstructure:"http"`
}

// NewViper creates a new viper instance.
func NewViper(configPath string) (*viper.Viper, error) {
	v := viper.New()
	v.SetConfigFile(configPath)
	v.SetDefault("logger.severity", "info")
	v.SetDefault("logger.format", "json")
	v.SetDefault("mqtt.broker", "")
	v.SetDefault("mqtt.username", "")
	v.SetDefault("mqtt.password", "")
	v.SetDefault("mqtt.topic", "aquacomputer/#")
	v.SetDefault("metric.regexp", "aquacomputer/(?P<name>[^/]+)/(?P<device>[^/]+)")
	v.SetDefault("metric.prefix", "aquacomputer")
	v.SetDefault("http.listen", "0.0.0.0")
	v.SetDefault("http.port", "9110")

	if err := v.ReadInConfig(); err != nil {
		if _, ok := err.(*fs.PathError); !ok {
			return nil, err
		}
	}

	for _, key := range v.AllKeys() {
		envKey := strings.ToUpper(strings.ReplaceAll(key, ".", "_"))
		err := v.BindEnv(key, envKey)
		if err != nil {
			return nil, err
		}
	}

	return v, nil
}

// NewConfig create a new config instance from the given configPath.
func NewConfig(configPath string) (*Config, error) {
	var config Config

	v, err := NewViper(configPath)
	if err != nil {
		return nil, err
	}

	if err := v.Unmarshal(&config); err != nil {
		return nil, err
	}

	return &config, nil
}

A  => internal/mqttclient/mqttclient.go +49 -0
@@ 1,49 @@
package mqttclient

import (
	"git.sr.ht/~sirn/aquacomputer-mqtt-exporter/internal/config"
	"git.sr.ht/~sirn/aquacomputer-mqtt-exporter/pkg/slogger"
	mqtt "github.com/eclipse/paho.mqtt.golang"
)

type MQTTClient struct {
	config *config.Config
	client mqtt.Client
	logger slogger.Logger
}

// NewMQTTClient creates a new MQTTClient.
func NewMQTTClient(config *config.Config, logger slogger.Logger) (*MQTTClient, error) {
	mqttclient := &MQTTClient{
		config: config,
		logger: logger,
	}

	mqttOptions := mqtt.NewClientOptions()
	mqttOptions.AddBroker(mqttclient.GetAddress())
	mqttOptions.SetAutoReconnect(true)
	mqttOptions.SetUsername(config.MQTT.Username)
	mqttOptions.SetPassword(config.MQTT.Password)

	client := mqtt.NewClient(mqttOptions)
	if token := client.Connect(); token.Wait() && token.Error() != nil {
		return nil, token.Error()
	}

	mqttclient.client = client
	return mqttclient, nil
}

// GetAddress returns the address the MQTTClient instance is connecting to.
func (m *MQTTClient) GetAddress() string {
	return m.config.MQTT.Broker
}

// Subscribe subscribes fn to the given topic.
func (m *MQTTClient) Subscribe(topic string, fn func(mqtt.Client, mqtt.Message)) error {
	if token := m.client.Subscribe(topic, 0, fn); token.Wait() && token.Error() != nil {
		return token.Error()
	}

	return nil
}

A  => internal/server/server.go +68 -0
@@ 1,68 @@
package server

import (
	"context"
	"fmt"
	"net/http"

	"git.sr.ht/~sirn/aquacomputer-mqtt-exporter/internal/config"
	"git.sr.ht/~sirn/aquacomputer-mqtt-exporter/pkg/slogger"
	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promhttp"
)

type Server struct {
	config     *config.Config
	httpServer *http.Server
	logger     slogger.Logger
	registry   *prometheus.Registry
}

// NewServer creates an instance of server.
func NewServer(
	config *config.Config,
	logger slogger.Logger,
	registry *prometheus.Registry) *Server {
	server := &Server{
		config:   config,
		logger:   logger,
		registry: registry,
	}

	server.httpServer = &http.Server{
		Addr: server.GetAddress(),
	}

	return server
}

// Run initializes root, middlewares, and runs the server.
func (s *Server) Run(ctx context.Context) error {
	http.Handle("/metrics", promhttp.HandlerFor(
		s.registry,
		promhttp.HandlerOpts{
			EnableOpenMetrics: true,
			Registry:          s.registry,
		},
	))

	if err := s.httpServer.ListenAndServe(); err != nil {
		return err
	}

	return nil
}

// GetAddress returns the address the metric server is listening as.
func (s *Server) GetAddress() string {
	return fmt.Sprintf("%s:%s", s.config.HTTP.Listen, s.config.HTTP.Port)
}

// GracefulStop gracefully stop the HTTP server.
func (s *Server) GracefulStop(ctx context.Context) error {
	if err := s.httpServer.Shutdown(ctx); err != nil {
		return err
	}

	return nil
}

A  => pkg/sgracer/sgracer.go +38 -0
@@ 1,38 @@
package sgracer

import (
	"context"
	"os/signal"
	"syscall"
	"time"

	"git.sr.ht/~sirn/aquacomputer-mqtt-exporter/pkg/slogger"
)

type GracefulRunner interface {
	Run(context.Context) error
	GracefulStop(context.Context) error
}

// GracefulStart starts the given `runner` and handle signal. The server will be
// gracefully shutdown when receiving signal 15 (SIGTERM), and signal 2 (SIGINT).
func GracefulStart(ctx context.Context, runner GracefulRunner) {
	ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	go func() {
		if err := runner.Run(ctx); err != nil {
			slogger.Fatalf("failed to start server: %s", err)
		}
	}()

	<-ctx.Done()
	stop()

	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()

	if err := runner.GracefulStop(ctx); err != nil {
		slogger.Fatalf("failed to gracefully stop server: %s", err)
	}
}

A  => pkg/slogger/slogger.go +219 -0
@@ 1,219 @@
package slogger

import (
	"log"
	"os"

	"go.uber.org/zap"
	"go.uber.org/zap/zapcore"
)

// Logger defines common interface for a logger.
type Logger interface {
	Debug(args ...interface{})
	Debugw(msg string, args ...interface{})
	Debugf(template string, args ...interface{})
	Info(args ...interface{})
	Infow(msg string, args ...interface{})
	Infof(template string, args ...interface{})
	Warn(args ...interface{})
	Warnw(msg string, args ...interface{})
	Warnf(template string, args ...interface{})
	Error(args ...interface{})
	Errorw(msg string, args ...interface{})
	Errorf(template string, args ...interface{})
	DPanic(args ...interface{})
	DPanicw(msg string, args ...interface{})
	DPanicf(template string, args ...interface{})
	Fatal(args ...interface{})
	Fatalw(msg string, args ...interface{})
	Fatalf(template string, args ...interface{})
	With(args ...interface{}) Logger
}

// Fatal prints a log using `fmt.Sprint` with fatal severity and exit.
// Note this function is a last-resort and should be used only in case where
// an error happens before `Logger` could be initialized.
func Fatal(v ...interface{}) {
	log.SetOutput(os.Stderr)
	log.SetFlags(0)
	log.Fatal(v...)
}

// Fatalf prints a log using `fmt.Sprintf` with fatal severity and exit.
// Note this function is a last-resort and should be used only in case where
// an error happens before `Logger` could be initialized.
func Fatalf(template string, v ...interface{}) {
	log.SetOutput(os.Stderr)
	log.SetFlags(0)
	log.Fatalf(template, v...)
}

// Print prints a log using `fmt.Print`.
// Note this command should only be used for printing command line outputs.
func Print(v ...interface{}) {
	log.SetOutput(os.Stderr)
	log.SetFlags(0)
	log.Print(v...)
}

// Printf prints a log using `fmt.Printf`.
// Note this command should only be used for printing command line outputs.
func Printf(template string, v ...interface{}) {
	log.SetOutput(os.Stderr)
	log.SetFlags(0)
	log.Printf(template, v...)
}

// BasicLogger defines the most basic type of logger without additional
// metadata associated to it.
type BasicLogger struct {
	logger        *zap.Logger
	sugaredLogger *zap.SugaredLogger
}

// NewBasicLogger initializes a `BasicLogger`.
func NewBasicLogger(logLevel string, logFormat string) (Logger, error) {
	zapLevel := zap.NewAtomicLevel()
	if err := zapLevel.UnmarshalText([]byte(logLevel)); err != nil {
		return nil, err
	}

	zapEncoderConfig := zap.NewProductionEncoderConfig()
	zapEncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
	zapEncoderConfig.LevelKey = "severity"
	zapConfig := zap.Config{
		Level:       zapLevel,
		Development: false,
		Sampling: &zap.SamplingConfig{
			Initial:    100,
			Thereafter: 100,
		},
		Encoding:         logFormat,
		EncoderConfig:    zapEncoderConfig,
		OutputPaths:      []string{"stderr"},
		ErrorOutputPaths: []string{"stderr"},
	}

	// Always skip 1 caller stack, otherwise caller will be this logger
	// wrapper instead of an actual caller.
	logger, err := zapConfig.Build(zap.AddCallerSkip(1))
	if err != nil {
		return nil, err
	}

	basicLogger := &BasicLogger{
		logger:        logger,
		sugaredLogger: logger.Sugar(),
	}

	return basicLogger, nil
}

// NewNopLogger returns a no-op Logger
func NewNopLogger() Logger {
	zapNopLogger := zap.NewNop()
	return &BasicLogger{
		logger:        zapNopLogger,
		sugaredLogger: zapNopLogger.Sugar(),
	}
}

// Debug prints a log using `fmt.Sprint` with debug severity.
func (l *BasicLogger) Debug(args ...interface{}) {
	l.sugaredLogger.Debug(args...)
}

// Debugw prints a log using `fmt.Sprint` with context at debug severity.
func (l *BasicLogger) Debugw(msg string, args ...interface{}) {
	l.sugaredLogger.Debugw(msg, args...)
}

// Debugf prints a log using `fmt.Sprintf` with debug severity.
func (l *BasicLogger) Debugf(template string, args ...interface{}) {
	l.sugaredLogger.Debugf(template, args...)
}

// Info prints a log using `fmt.Sprint` with info severity.
func (l *BasicLogger) Info(args ...interface{}) {
	l.sugaredLogger.Info(args...)
}

// Infow prints a log using `fmt.Sprint` with context at info severity.
func (l *BasicLogger) Infow(msg string, args ...interface{}) {
	l.sugaredLogger.Infow(msg, args...)
}

// Infof prints a log using `fmt.Sprintf` with info severity.
func (l *BasicLogger) Infof(template string, args ...interface{}) {
	l.sugaredLogger.Infof(template, args...)
}

// Warn prints a log using `fmt.Sprint` with warn severity.
func (l *BasicLogger) Warn(args ...interface{}) {
	l.sugaredLogger.Warn(args...)
}

// Warnw prints a log using `fmt.Sprint` with context at warn severity.
func (l *BasicLogger) Warnw(msg string, args ...interface{}) {
	l.sugaredLogger.Warnw(msg, args...)
}

// Warnf prints a log using `fmt.Sprintf` with warn severity.
func (l *BasicLogger) Warnf(template string, args ...interface{}) {
	l.sugaredLogger.Warnf(template, args...)
}

// Error prints a log using `fmt.Sprint` with error severity.
func (l *BasicLogger) Error(args ...interface{}) {
	l.sugaredLogger.Error(args...)
}

// Errorw prints a log using `fmt.Sprint` with context at error severity.
func (l *BasicLogger) Errorw(msg string, args ...interface{}) {
	l.sugaredLogger.Errorw(msg, args...)
}

// Errorf prints a log using `fmt.Sprintf` with error severity.
func (l *BasicLogger) Errorf(template string, args ...interface{}) {
	l.sugaredLogger.Errorf(template, args...)
}

// DPanic prints a log using `fmt.Sprint` with panic severity.
func (l *BasicLogger) DPanic(args ...interface{}) {
	l.sugaredLogger.DPanic(args...)
}

// DPanicw prints a log using `fmt.Sprint` with context at panic severity.
func (l *BasicLogger) DPanicw(msg string, args ...interface{}) {
	l.sugaredLogger.DPanicw(msg, args...)
}

// DPanicf prints a log using `fmt.Sprintf` with panic severity.
func (l *BasicLogger) DPanicf(template string, args ...interface{}) {
	l.sugaredLogger.DPanicf(template, args...)
}

// Fatal prints a log using `fmt.Sprint` with fatal severity.
func (l *BasicLogger) Fatal(args ...interface{}) {
	l.sugaredLogger.Fatal(args...)
}

// Fatalw prints a log using `fmt.Sprint` with context at fatal severity.
func (l *BasicLogger) Fatalw(msg string, args ...interface{}) {
	l.sugaredLogger.Fatalw(msg, args...)
}

// Fatalf prints a log using `fmt.Sprintf` with fatal severity.
func (l *BasicLogger) Fatalf(template string, args ...interface{}) {
	l.sugaredLogger.Fatalf(template, args...)
}

// With adds a variadic number of fields to the logging context.
func (l *BasicLogger) With(args ...interface{}) Logger {
	newSugardLogger := l.sugaredLogger.With(args...)
	return &BasicLogger{
		logger:        newSugardLogger.Desugar(),
		sugaredLogger: newSugardLogger,
	}
}

A  => scripts/gen-version.sh +34 -0
@@ 1,34 @@
#!/bin/sh -e
#
# Generates a version number for use in e.g. tags.
#

BASE_DIR=$(cd "$(dirname "$0")/.." || exit 1; pwd -P)
cd "$BASE_DIR/" || exit 1

# If tag exists, let Git generates the version number in the format of
# `1.1.0-5-e8df964a` where `1.1.0` is the latest tag with common ancestor
# as this commit, `5` is number of commits since the tag, and `e8df964a`
# being the rev of the latest commit.
#
VERSION=$(git describe --tags --dirty 2>/dev/null)

if [ -n "$VERSION" ]; then
    echo "$VERSION"
    exit
fi

# If tag doesn't exist and worktree isn't dirty, we try to replicate the
# same format with `0.0.0-0-e8df964a` where `e8df964a` is the rev of the
# latest commit.
#
REV=$(git rev-parse --short HEAD 2>/dev/null)

if [ -n "$REV" ]; then
    echo "v0.0.0-0-$REV"
    exit
fi

# Everything fails, use `0.0.0-dev`
#
echo "v0.0.0-0"