pax_global_header00006660000000000000000000000064151235447620014523gustar00rootroot0000000000000052 comment=d119b23d98a0755bff1f52b7646f4b900e7f1372 prometheus-sensor-exporter-0.1.0/000077500000000000000000000000001512354476200170715ustar00rootroot00000000000000prometheus-sensor-exporter-0.1.0/.github/000077500000000000000000000000001512354476200204315ustar00rootroot00000000000000prometheus-sensor-exporter-0.1.0/.github/workflows/000077500000000000000000000000001512354476200224665ustar00rootroot00000000000000prometheus-sensor-exporter-0.1.0/.github/workflows/go.yaml000066400000000000000000000006001512354476200237530ustar00rootroot00000000000000--- name: Go on: # yamllint disable-line rule:truthy push: pull_request: jobs: build_and_test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - name: Set up Go uses: actions/setup-go@v6 with: go-version-file: 'go.mod' - name: Build run: go build -v ./... - name: Test run: go test -v ./... prometheus-sensor-exporter-0.1.0/.gitignore000066400000000000000000000001111512354476200210520ustar00rootroot00000000000000/coverage* /man/prometheus-sensor-exporter.1 /prometheus-sensor-exporter prometheus-sensor-exporter-0.1.0/LICENSE000077500000000000000000000014501512354476200201010ustar00rootroot00000000000000prometheus-sensor-exporter is licensed under ISC: Copyright (C) 2021-2025, Benjamin Drung Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. prometheus-sensor-exporter-0.1.0/README.md000066400000000000000000000022601512354476200203500ustar00rootroot00000000000000prometheus-sensor-exporter ========================== `prometheus-sensor-exporter` is a [Prometheus](https://prometheus.io/) exporter for temperature and humidity sensors. Supported sensors ----------------- * Bosch Sensortec BME280, BMP180, BMP280, BMP388 (using https://github.com/d2r2/go-bsbmp) * Sensirion SHT30, SHT31, SHT35 (using https://github.com/d2r2/go-sht3x) Supported flags --------------- * address: I²C address (uint8) * bus: I²C bus number (int) * repeatability: low, medium, or high (only SHT sensors) * temp_offset: fixed temperature offset in °C to add (float64) * humidity_offset: fixed humidity offset in percent to add (float64) Example usage ------------- ``` prometheus-sensor-exporter BME280,bus=0 SHT35,bus=1,address=0x45 ``` Build ===== ``` go get go build -ldflags "\ -X github.com/prometheus/common/version.Branch=$(git branch --show-current) \ -X github.com/prometheus/common/version.Revision=$(git rev-parse --short HEAD) \ -X github.com/prometheus/common/version.Version=0.1.0" ``` Test ==== ``` go test -coverprofile=coverage.out -v go tool cover -html=coverage.out -o coverage.html ``` Formatting ========== ``` goimports -w . go fmt ``` prometheus-sensor-exporter-0.1.0/go.mod000066400000000000000000000020621512354476200201770ustar00rootroot00000000000000module github.com/bdrung/prometheus-sensor-exporter go 1.24.0 toolchain go1.24.4 require ( github.com/d2r2/go-bsbmp v0.0.0-20190515110334-3b4b3aea8375 github.com/d2r2/go-i2c v0.0.0-20191123181816-73a8a799d6bc github.com/d2r2/go-logger v0.0.0-20210606094344-60e9d1233e22 github.com/d2r2/go-sht3x v0.0.0-20181222062132-074abc261905 github.com/prometheus/client_golang v1.23.0 github.com/sirupsen/logrus v1.9.3 github.com/spf13/pflag v1.0.10 ) require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/d2r2/go-shell v0.0.0-20211022052110-f591c27e3e2e // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.4 // indirect github.com/prometheus/procfs v0.19.2 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/sys v0.39.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) prometheus-sensor-exporter-0.1.0/go.sum000066400000000000000000000102331512354476200202230ustar00rootroot00000000000000github.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.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/d2r2/go-bsbmp v0.0.0-20190515110334-3b4b3aea8375 h1:vdUOwcZdV+bBfGUUh5oPPWSzw9p+lBnNSuGgQwGpCH4= github.com/d2r2/go-bsbmp v0.0.0-20190515110334-3b4b3aea8375/go.mod h1:3iz1WHlYJU9b4NJei+Q8G7DN3K05arcCMlOQ+qNCDjo= github.com/d2r2/go-i2c v0.0.0-20191123181816-73a8a799d6bc h1:HLRSIWzUGMLCq4ldt0W1GLs3nnAxa5EGoP+9qHgh6j0= github.com/d2r2/go-i2c v0.0.0-20191123181816-73a8a799d6bc/go.mod h1:AwxDPnsgIpy47jbGXZHA9Rv7pDkOJvQbezPuK1Y+nNk= github.com/d2r2/go-logger v0.0.0-20210606094344-60e9d1233e22 h1:nO+SY4KOMsF/LsZ5EtbSKhiT3M6sv/igo2PEru/xEHI= github.com/d2r2/go-logger v0.0.0-20210606094344-60e9d1233e22/go.mod h1:eSx+YfcVy5vCjRZBNIhpIpfCGFMQ6XSOSQkDk7+VCpg= github.com/d2r2/go-shell v0.0.0-20211022052110-f591c27e3e2e h1:6rbw4kecquuE5mELvn9DJqrFfTLkeITQSkv8chVAX2Q= github.com/d2r2/go-shell v0.0.0-20211022052110-f591c27e3e2e/go.mod h1:yqtlOXB0bWzWgM4wZ9BdZ75OmXSiFYSKrZ3TZlPaePQ= github.com/d2r2/go-sht3x v0.0.0-20181222062132-074abc261905 h1:Dg6AjWosDuB0l1SzCzlCy8oSAmVNDytLZ5OgmROWGj0= github.com/d2r2/go-sht3x v0.0.0-20181222062132-074abc261905/go.mod h1:tEUTTCANDHBzOFFeZfOGaI2zBKebJ40JqRpgYg3HSxo= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc= github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= prometheus-sensor-exporter-0.1.0/humidity.go000066400000000000000000000034331512354476200212570ustar00rootroot00000000000000// Copyright (C) 2021, Benjamin Drung // SPDX-License-Identifier: ISC package main import "math" const ( gasConstant = 8.31446261815324 // molar gas constant R in kg * m² / (s² * K * mol) molarMassWater = 0.01801528 // molar mass of water M(H2O) in kg / mol gasConstantWater = gasConstant / molarMassWater // specific gas constant for water vapor in m² / (s² * K) ) // saturationVaporPressureWater calculates the saturation vapour pressure of water in hectopascal // (hPa) with Arden Buck equation, because it is the most accurate formula for room temperatures. // See https://en.wikipedia.org/wiki/Vapour_pressure_of_water#Accuracy_of_different_formulations func saturationVaporPressureWater(tempCelsius float64) float64 { return 6.1121 * math.Exp((18.678-tempCelsius/234.5)*(tempCelsius/(257.14+tempCelsius))) } // Relative2AbsoluteHumidity calculates the absolute humidity in g/m³ for a given // relative humidity and temperature in Celsius. // // The humidity definitions and the ideal gas law were used for deriving the formula: // 1. AH = m_water / V // 2. RH = p_water / p*_water // 3. p_water = (m_water / V) * R_water * T // // Resulting formula: AH = RH * p*_water / (R_water * T) // // Symbols: // AH: absolute humidity // m_water: mass of the water vapor // p_water: partial vapor pressure of water // p*_water: saturation vapour pressure of water // R_water: specific gas constant for water vapor // RH: relative humidity // T: temperature (in Kelvin) // V: volume of the air and water vapor mixture func Relative2AbsoluteHumidity(relativeHumidity float64, tempCelsius float64) float64 { tempKelvin := tempCelsius + 273.15 return 1000 * relativeHumidity * saturationVaporPressureWater(tempCelsius) / (gasConstantWater * tempKelvin) } prometheus-sensor-exporter-0.1.0/humidity_test.go000066400000000000000000000014361512354476200223170ustar00rootroot00000000000000// Copyright (C) 2021, Benjamin Drung // SPDX-License-Identifier: ISC package main import ( "fmt" "math" "testing" ) func TestRelative2AbsoluteHumidity(t *testing.T) { tests := []struct { rh float64 tempCelsius float64 ah float64 }{ {40.0, 20.0, 6.9}, {50.0, 15.0, 6.4}, {70.0, 20.0, 12.1}, {80.0, 15.0, 10.3}, {80.0, -10.0, 1.9}, {20.0, 50.0, 16.6}, } for _, test := range tests { t.Run(fmt.Sprintf("rh_%.0f_temp_%.0f", test.rh, test.tempCelsius), func(t *testing.T) { ah := Relative2AbsoluteHumidity(test.rh, test.tempCelsius) if math.Abs(ah-test.ah) > 0.05 { t.Errorf( "Absolute humidity for %f%% humidity at %f° C was incorrect, got: %f, want: %f.", test.rh, test.tempCelsius, ah, test.ah) } }) } } prometheus-sensor-exporter-0.1.0/logger.go000066400000000000000000000003251512354476200206770ustar00rootroot00000000000000// Copyright (C) 2021-2025, Benjamin Drung // SPDX-License-Identifier: ISC package main import logger "github.com/d2r2/go-logger" var lg = logger.NewPackageLogger("sensor", logger.InfoLevel) prometheus-sensor-exporter-0.1.0/man/000077500000000000000000000000001512354476200176445ustar00rootroot00000000000000prometheus-sensor-exporter-0.1.0/man/README.md000066400000000000000000000003621512354476200211240ustar00rootroot00000000000000prometheus-sensor-exporter man pages ==================================== The prometheus-sensor-exporter man page can be build with [Asciidoctor](https://asciidoctor.org/): ```sh asciidoctor -b manpage prometheus-sensor-exporter.1.adoc ``` prometheus-sensor-exporter-0.1.0/man/prometheus-sensor-exporter.1.adoc000066400000000000000000000040531512354476200262050ustar00rootroot00000000000000prometheus-sensor-exporter(1) ============================= Benjamin Drung :doctype: manpage :manmanual: prometheus-sensor-exporter :mansource: prometheus-sensor-exporter 0.1.0 :manversion: 0.1.0 == Name prometheus-sensor-exporter - Prometheus exporter for I²C temperature and humidity sensors == Synopsis *prometheus-sensor-exporter* [*--web.listen-address* _ADDRESS_] [*--web.telemetry-path* _PATH_] _SENSOR_... *prometheus-sensor-exporter* {*-h*|*--help*} == Description *prometheus-sensor-exporter* is a Prometheus exporter for I²C temperature and humidity sensors. Supported sensors: * Bosch Sensortec BME280, BMP180, BMP280, BMP388 * Sensirion SHT30, SHT31, SHT35 == Options *--web.listen-address* _ADDRESS_:: Address on which to expose metrics and web interface. (default _:9775_) *--web.telemetry-path* _PATH_ Path under which to expose metrics. (default _/metrics_) _SENSOR_:: One or more sensor configurations. Each sensor configuration is a comma-separated list. The first element is the model name. All following elements are sensor options (see next section). == Sensor options _MODEL_:: Model name of the sensor. Supported models: + * BME280 * BMP180 * BMP280 * BMP388 * SHT30 * SHT31 * SHT35 *address*=_ADDRESS_:: I²C address (uint8) (default depends on the model) *bus*=_BUS_:: I²C bus number (int) (default _0_) *repeatability*=low|medium|high:: Repeatability (only SHT sensors) *temp_offset*=_OFFSET_:: Fixed temperature offset in °C. This offset will be added to the measured temperature. (float64) (default: _0.0_) *humidity_offset*=_OFFSET_:: Fixed humidity offset in percent. This offset will be added to the measured relative humidity. (float64) (default: _0.0_) == Examples Example for monitor two sensors: one Bosch BME280 on I²C bus 0 (using the default address) and one Sensirion SHT35 on I²C bus 1 with address 0x45. [example,shell] ---- prometheus-sensor-exporter BME280,bus=0 SHT35,bus=1,address=0x45 ---- == Copying Copyright (C) 2021-2025 Benjamin Drung. Free use of this software is granted under the terms of the ISC License. prometheus-sensor-exporter-0.1.0/prometheus-sensor-exporter.go000066400000000000000000000273261512354476200250020ustar00rootroot00000000000000// Copyright (C) 2021-2025, Benjamin Drung // SPDX-License-Identifier: ISC package main import ( "fmt" "math" "net/http" "strconv" "strings" "sync" bsbmp "github.com/d2r2/go-bsbmp" i2c "github.com/d2r2/go-i2c" logger "github.com/d2r2/go-logger" sht3x "github.com/d2r2/go-sht3x" "github.com/prometheus/client_golang/prometheus" versioncollector "github.com/prometheus/client_golang/prometheus/collectors/version" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/sirupsen/logrus" "github.com/spf13/pflag" ) type Readings struct { temperature *float64 humidity *float64 } type Sensor interface { Poll() (Readings, error) Labels() prometheus.Labels } type BMPSensor struct { Address uint8 Bus int Model string bmp *bsbmp.BMP mutex sync.Mutex } func NewBMPSensor( address uint8, bus int, model string, sensorType bsbmp.SensorType, ) (*BMPSensor, error) { logrus.Infof("New BMP sensor: %s,address=0x%x,bus=%d", model, address, bus) i2c, err := i2c.NewI2C(address, bus) if err != nil { return nil, err } bmp, err := bsbmp.NewBMP(sensorType, i2c) if err != nil { return nil, err } return &BMPSensor{ Address: address, Bus: bus, Model: model, bmp: bmp, }, nil } func (s BMPSensor) Labels() prometheus.Labels { return prometheus.Labels{ "address": fmt.Sprintf("0x%x", s.Address), "bus": fmt.Sprintf("%d", s.Bus), "model": s.Model, } } func (s BMPSensor) Poll() (Readings, error) { var readings Readings s.mutex.Lock() temp, err := s.bmp.ReadTemperatureC(bsbmp.ACCURACY_STANDARD) s.mutex.Unlock() if err != nil { return readings, err } rounded_temp := round64(float64(temp), 2) readings.temperature = &rounded_temp // TODO: read temperature and humidity in one go for BME280 s.mutex.Lock() supported, rh, err := s.bmp.ReadHumidityRH(bsbmp.ACCURACY_STANDARD) s.mutex.Unlock() if err != nil { return readings, err } if supported { rounded_rh := round64(float64(rh), 2) readings.humidity = &rounded_rh } // TODO: Read pressure as well return readings, nil } type SHT3xSensor struct { Address uint8 Bus int Model string I2C *i2c.I2C SHT3X sht3x.SHT3X mutex sync.Mutex repeatability sht3x.MeasureRepeatability repeatability_str string } func NewSHT3xSensor( address uint8, bus int, model string, repeatability sht3x.MeasureRepeatability, repeatability_str string, ) (*SHT3xSensor, error) { logrus.Infof( "New SHT3x sensor: %s,address=0x%x,bus=%d,repeatability=%s", model, address, bus, repeatability_str, ) i2c, err := i2c.NewI2C(address, bus) if err != nil { return nil, err } return &SHT3xSensor{ Address: address, Bus: bus, Model: model, I2C: i2c, SHT3X: *sht3x.NewSHT3X(), repeatability: repeatability, repeatability_str: repeatability_str, }, nil } func (s SHT3xSensor) Labels() prometheus.Labels { return prometheus.Labels{ "address": fmt.Sprintf("0x%x", s.Address), "bus": fmt.Sprintf("%d", s.Bus), "model": s.Model, "repeatability": s.repeatability_str, } } func (s SHT3xSensor) Poll() (Readings, error) { var readings Readings s.mutex.Lock() temp, rh, err := s.SHT3X.ReadTemperatureAndRelativeHumidity(s.I2C, s.repeatability) s.mutex.Unlock() if err != nil { return readings, err } rounded_temp := round64(float64(temp), 2) rounded_rh := round64(float64(rh), 2) readings.temperature = &rounded_temp readings.humidity = &rounded_rh return readings, nil } type SensorFlags struct { Model string Address *uint8 Bus *int Repeatability string TempOffset float64 HumidityOffset float64 } func parseSensorFlags(sensor string) (SensorFlags, error) { var flags SensorFlags fields := strings.Split(sensor, ",") flags.Model = fields[0] for _, field := range fields[1:] { key_value := strings.SplitN(field, "=", 2) var value string if len(key_value) == 2 { value = key_value[1] } switch key_value[0] { case "address": if address8, err := strconv.ParseUint(value, 0, 8); err == nil { address := uint8(address8) flags.Address = &address } else { return flags, fmt.Errorf("Specified address '%s' is not an unsigned integer: %s", value, err) } case "bus": if bus32, err := strconv.ParseInt(value, 0, 32); err == nil { bus := int(bus32) flags.Bus = &bus } else { return flags, fmt.Errorf("Specified bus '%s' is not an integer: %s", value, err) } case "repeatability": flags.Repeatability = value case "temp_offset": var err error flags.TempOffset, err = strconv.ParseFloat(value, 64) if err != nil { return flags, fmt.Errorf("Failed to parse temperature offset '%s': %s", value, err) } case "humidity_offset": var err error flags.HumidityOffset, err = strconv.ParseFloat(value, 64) if err != nil { return flags, fmt.Errorf("Failed to parse humidity offset '%s': %s", value, err) } default: return flags, fmt.Errorf("Unknown sensor option '%s'.", key_value[0]) } } return flags, nil } func (s SensorFlags) NewBMPSensor(sensorType bsbmp.SensorType) (*BMPSensor, error) { // Defaults if s.Address == nil { address := uint8(0x76) s.Address = &address } if s.Bus == nil { bus := 0 s.Bus = &bus } return NewBMPSensor(*s.Address, *s.Bus, s.Model, sensorType) } func (s SensorFlags) NewSHT3xSensor() (*SHT3xSensor, error) { // Defaults if s.Address == nil { address := uint8(0x45) s.Address = &address } if s.Bus == nil { bus := 0 s.Bus = &bus } if s.Repeatability == "" { s.Repeatability = "high" } var repeatability sht3x.MeasureRepeatability switch s.Repeatability { case "low": repeatability = sht3x.RepeatabilityLow case "medium": repeatability = sht3x.RepeatabilityMedium case "high": repeatability = sht3x.RepeatabilityHigh default: return nil, fmt.Errorf("Unknown repeatability: %s", s.Repeatability) } return NewSHT3xSensor(*s.Address, *s.Bus, s.Model, repeatability, s.Repeatability) } func (s SensorFlags) NewSensor() (Sensor, error) { switch s.Model { case "BME280": return s.NewBMPSensor(bsbmp.BME280) case "BMP180": return s.NewBMPSensor(bsbmp.BMP180) case "BMP280": return s.NewBMPSensor(bsbmp.BMP280) case "BMP388": return s.NewBMPSensor(bsbmp.BMP388) case "SHT30", "SHT31", "SHT35": return s.NewSHT3xSensor() default: return nil, fmt.Errorf("Invalid/Unsupported sensor model '%s'!", s.Model) } } func (s SensorFlags) String() string { var b strings.Builder b.WriteString(s.Model) if s.Address != nil { fmt.Fprintf(&b, ",address=0x%x", *s.Address) } if s.Bus != nil { fmt.Fprintf(&b, ",bus=%d", *s.Bus) } if s.Repeatability != "" { fmt.Fprintf(&b, ",repeatability=%s", s.Repeatability) } if s.TempOffset != 0.0 { fmt.Fprintf(&b, ",temp_offset=%g", s.TempOffset) } if s.HumidityOffset != 0.0 { fmt.Fprintf(&b, ",humidity_offset=%g", s.HumidityOffset) } return b.String() } type sensorCollector struct { Sensor Sensor Up *prometheus.Desc TemperatureC *prometheus.Desc HumidityRH *prometheus.Desc HumidityGram *prometheus.Desc RawTemperatureC *prometheus.Desc RawHumidityRH *prometheus.Desc RawHumidityGram *prometheus.Desc TempOffset float64 HumidityOffset float64 } func NewSensorCollector(s Sensor, tempOffset float64, humidityOffset float64) *sensorCollector { labels := s.Labels() return &sensorCollector{ Sensor: s, TemperatureC: prometheus.NewDesc( "sensor_temperature_celsius", "Temperature in Celsius", nil, labels, ), HumidityRH: prometheus.NewDesc( "sensor_humidity_percent", "Relative humidity in percent", nil, labels, ), HumidityGram: prometheus.NewDesc( "sensor_humidity_grams_per_cubic_meter", "Absolute humidity in gram / cubic meter", nil, labels, ), Up: prometheus.NewDesc( "sensor_up", "Value is 1 if reading sensor date was successful, 0 otherwise.", nil, labels, ), RawTemperatureC: prometheus.NewDesc( "sensor_raw_temperature_celsius", "Uncorrected temperature in Celsius", nil, labels, ), RawHumidityRH: prometheus.NewDesc( "sensor_raw_humidity_percent", "Uncorrected relative humidity in percent", nil, labels, ), RawHumidityGram: prometheus.NewDesc( "sensor_raw_humidity_grams_per_cubic_meter", "Uncorrected absolute humidity in gram / cubic meter", nil, labels, ), TempOffset: tempOffset, HumidityOffset: humidityOffset, } } func (collector *sensorCollector) Collect(ch chan<- prometheus.Metric) { readings, err := collector.Sensor.Poll() if err != nil { logrus.Print(err) ch <- prometheus.MustNewConstMetric(collector.Up, prometheus.GaugeValue, 0.0) } else { ch <- prometheus.MustNewConstMetric(collector.Up, prometheus.GaugeValue, 1) } if readings.temperature != nil { ch <- prometheus.MustNewConstMetric( collector.TemperatureC, prometheus.GaugeValue, *readings.temperature+collector.TempOffset, ) ch <- prometheus.MustNewConstMetric( collector.RawTemperatureC, prometheus.GaugeValue, *readings.temperature, ) } if readings.humidity != nil { ch <- prometheus.MustNewConstMetric( collector.HumidityRH, prometheus.GaugeValue, *readings.humidity+collector.HumidityOffset, ) ch <- prometheus.MustNewConstMetric( collector.RawHumidityRH, prometheus.GaugeValue, *readings.humidity, ) if readings.temperature != nil { absoluteHumidity := Relative2AbsoluteHumidity( *readings.humidity+collector.HumidityOffset, *readings.temperature+collector.TempOffset, ) ch <- prometheus.MustNewConstMetric( collector.HumidityGram, prometheus.GaugeValue, round64(absoluteHumidity, 2), ) rawAbsoluteHumidity := Relative2AbsoluteHumidity( *readings.humidity, *readings.temperature, ) ch <- prometheus.MustNewConstMetric( collector.RawHumidityGram, prometheus.GaugeValue, round64(rawAbsoluteHumidity, 2), ) } } } func (collector *sensorCollector) Describe(ch chan<- *prometheus.Desc) { ch <- collector.TemperatureC ch <- collector.HumidityRH ch <- collector.HumidityGram ch <- collector.Up ch <- collector.RawTemperatureC ch <- collector.RawHumidityRH ch <- collector.RawHumidityGram } func parseSensors(args []string) ([]SensorFlags, error) { sensors := make([]SensorFlags, len(args)) for i, arg := range args { sensor, err := parseSensorFlags(arg) if err != nil { return nil, fmt.Errorf("sensor %d '%s': %w", i+1, arg, err) } sensors[i] = sensor } return sensors, nil } func round64(value float64, precision int) float64 { return math.Round(value*math.Pow10(precision)) / math.Pow10(precision) } func main() { listenAddress := pflag.String( "web.listen-address", ":9775", "Address on which to expose metrics and web interface.", ) metricsPath := pflag.String( "web.telemetry-path", "/metrics", "Path under which to expose metrics.", ) pflag.Parse() sensors, err := parseSensors(pflag.Args()) if err != nil { logrus.Fatal(err) } logger.ChangePackageLogLevel("bsbmp", logger.InfoLevel) logger.ChangePackageLogLevel("i2c", logger.InfoLevel) logger.ChangePackageLogLevel("sht3x", logger.InfoLevel) for _, flags := range sensors { sensor, err := flags.NewSensor() if err != nil { logrus.Fatal(err) } collector := NewSensorCollector(sensor, flags.TempOffset, flags.HumidityOffset) prometheus.MustRegister(collector) } prometheus.MustRegister(versioncollector.NewCollector("sensor_exporter")) logrus.Infof( "Serving Prometheus sensor exporter on %s%s - for example http://localhost%s%s", *listenAddress, *metricsPath, *listenAddress, *metricsPath, ) http.Handle(*metricsPath, promhttp.Handler()) logrus.Fatal(http.ListenAndServe(*listenAddress, nil)) } prometheus-sensor-exporter-0.1.0/prometheus-sensor-exporter.service000066400000000000000000000006701512354476200260260ustar00rootroot00000000000000[Unit] Description=Prometheus exporter for I²C temperature and humidity sensors Documentation=man:prometheus-sensor-exporter(1) After=network.target [Service] User=prometheus Group=i2c EnvironmentFile=/etc/default/prometheus-sensor-exporter ExecStart=/usr/bin/prometheus-sensor-exporter $ARGS Restart=on-failure LimitFSIZE=0 NoNewPrivileges=true PrivateTmp=true ProtectHome=true ProtectSystem=strict [Install] WantedBy=multi-user.target prometheus-sensor-exporter-0.1.0/prometheus-sensor-exporter_test.go000066400000000000000000000054131512354476200260320ustar00rootroot00000000000000// Copyright (C) 2021-2025, Benjamin Drung // SPDX-License-Identifier: ISC package main import ( "reflect" "strings" "testing" ) func intptr(v int) *int { return &v } func uint8ptr(v uint8) *uint8 { return &v } func TestParseSensorFlags(t *testing.T) { flags, err := parseSensorFlags( "SHT35,bus=1,address=0x45,repeatability=high,temp_offset=-0.5,humidity_offset=2.5") if err != nil { t.Errorf("Failed to parse flags: %s", err) } if flags.String() != "SHT35,address=0x45,bus=1,repeatability=high,temp_offset=-0.5,humidity_offset=2.5" { t.Errorf("String representation is incorrect: %s", flags) } } func TestParseSensorFlagsFailure(t *testing.T) { tests := []struct { name string sensor string wantedErr string }{ {"model", "SHT35,foo=bar", "Unknown sensor option 'foo'."}, {"address", "SHT35,address=-42", "Specified address '-42' is not an unsigned integer: "}, {"bus", "SHT35,bus=foo", "Specified bus 'foo' is not an integer: "}, {"temp_offset", "SHT35,temp_offset=caffee", "Failed to parse temperature offset 'caffee': "}, {"humidity_offset", "SHT35,humidity_offset=hum", "Failed to parse humidity offset 'hum': "}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { _, err := parseSensorFlags(test.sensor) if err == nil || !strings.Contains(err.Error(), test.wantedErr) { t.Errorf( "Incorrect error for sensor '%s', got: %v, want: %s.", test.sensor, err, test.wantedErr) } }) } } func TestParseSensors(t *testing.T) { args := []string{"SHT35,bus=1,address=0x46", "BME280,bus=0"} want := []SensorFlags{ {Model: "SHT35", Address: uint8ptr(0x46), Bus: intptr(1)}, {Model: "BME280", Bus: intptr(0)}, } got, err := parseSensors(args) if err != nil { t.Fatalf("parseSensors() unexpected error: %v", err) } if !reflect.DeepEqual(got, want) { t.Errorf("parseSensors() = %v, want %v", got, want) } } func TestParseSensorsInvalid(t *testing.T) { args := []string{"SHT31,badflag"} wantedErr := "sensor 1 'SHT31,badflag': Unknown sensor option 'badflag'" _, err := parseSensors(args) if err == nil || !strings.Contains(err.Error(), wantedErr) { t.Fatalf("parseSensors() expected error '%s', got %v", wantedErr, err) } } func TestRound64(t *testing.T) { tests := []struct { name string value float64 precision int want float64 }{ {"round up", 3.14159, 2, 3.14}, {"round down", 2.71828, 2, 2.72}, {"zero precision", 2.71828, 0, 3}, {"negative number", -1.2345, 2, -1.23}, {"no rounding needed", 5.0, 2, 5.0}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { got := round64(test.value, test.precision) if got != test.want { t.Errorf("round64(%v, %d) = %v, want %v", test.value, test.precision, got, test.want) } }) } }