Initila commit
Build Docker image on push / docker (push) Successful in 22s

This commit is contained in:
2026-08-18 00:46:59 +02:00
commit 9db7aa2560
28 changed files with 2823 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
package collector
import (
"sync/atomic"
"github.com/prometheus/client_golang/prometheus"
)
type baseCollector struct {
device string
product string
up *prometheus.Desc
scrapeDuration *prometheus.Desc
scrapeErrors *prometheus.Desc
errorCount atomic.Uint64
}
func newBaseCollector(device, product string) baseCollector {
labels := []string{"device", "product"}
return baseCollector{
device: device,
product: product,
up: prometheus.NewDesc(
"shelly_up",
"Whether the last status request to the Shelly device succeeded (1) or failed (0).",
labels,
nil,
),
scrapeDuration: prometheus.NewDesc(
"shelly_scrape_duration_seconds",
"Duration of the last Shelly device status request in seconds.",
labels,
nil,
),
scrapeErrors: prometheus.NewDesc(
"shelly_scrape_errors_total",
"Total number of failed Shelly device status requests.",
labels,
nil,
),
}
}
func (c *baseCollector) describe(channel chan<- *prometheus.Desc) {
channel <- c.up
channel <- c.scrapeDuration
channel <- c.scrapeErrors
}
func (c *baseCollector) collect(channel chan<- prometheus.Metric, success bool, durationSeconds float64) {
up := float64(0)
if success {
up = 1
} else {
c.errorCount.Add(1)
}
labels := []string{c.device, c.product}
channel <- prometheus.MustNewConstMetric(c.up, prometheus.GaugeValue, up, labels...)
channel <- prometheus.MustNewConstMetric(c.scrapeDuration, prometheus.GaugeValue, durationSeconds, labels...)
channel <- prometheus.MustNewConstMetric(c.scrapeErrors, prometheus.CounterValue, float64(c.errorCount.Load()), labels...)
}
+85
View File
@@ -0,0 +1,85 @@
// Package collector maps Shelly product types to Prometheus collectors.
package collector
import (
"fmt"
"sort"
"strings"
"sync"
"unicode"
"github.com/prometheus/client_golang/prometheus"
"lostak.dev/shelly-exporter/configuration"
"lostak.dev/shelly-exporter/shelly"
)
// Factory constructs a collector for one configured device.
type Factory func(configuration.DeviceConfiguration, shelly.StatusClient) (prometheus.Collector, error)
var (
factoriesMutex sync.RWMutex
factories = make(map[string]Factory)
)
// RegisterProduct adds support for a product identifier. Product packages call it from init.
func RegisterProduct(product string, factory Factory) error {
product = normalizeProduct(product)
if product == "" {
return fmt.Errorf("product identifier cannot be empty")
}
if factory == nil {
return fmt.Errorf("factory for product %q cannot be nil", product)
}
factoriesMutex.Lock()
defer factoriesMutex.Unlock()
if _, exists := factories[product]; exists {
return fmt.Errorf("product %q is already registered", product)
}
factories[product] = factory
return nil
}
// NewDeviceCollector creates the registered collector for a configured product.
func NewDeviceCollector(config configuration.DeviceConfiguration, client shelly.StatusClient) (prometheus.Collector, error) {
product := normalizeProduct(config.Product)
factoriesMutex.RLock()
factory, exists := factories[product]
factoriesMutex.RUnlock()
if !exists {
return nil, fmt.Errorf("unsupported Shelly product %q (supported: %s)", config.Product, strings.Join(SupportedProducts(), ", "))
}
config.Product = product
return factory(config, client)
}
// SupportedProducts returns all registered identifiers in stable order.
func SupportedProducts() []string {
factoriesMutex.RLock()
defer factoriesMutex.RUnlock()
products := make([]string, 0, len(factories))
for product := range factories {
products = append(products, product)
}
sort.Strings(products)
return products
}
func normalizeProduct(product string) string {
var normalized strings.Builder
separator := false
for _, character := range strings.ToLower(strings.TrimSpace(product)) {
if unicode.IsLetter(character) || unicode.IsDigit(character) {
if separator && normalized.Len() > 0 {
normalized.WriteByte('_')
}
normalized.WriteRune(character)
separator = false
} else {
separator = true
}
}
return normalized.String()
}
+514
View File
@@ -0,0 +1,514 @@
package collector
import (
"context"
"fmt"
"sort"
"strconv"
"time"
"github.com/prometheus/client_golang/prometheus"
log "github.com/sirupsen/logrus"
"lostak.dev/shelly-exporter/configuration"
"lostak.dev/shelly-exporter/shelly"
)
// These identifiers use the common Gen1 /status protocol. The generic gen1
// identifier is available for compatible devices not explicitly listed here.
var gen1Products = []string{
"gen1",
"plug", "plug_s",
"shelly_1", "shelly_1pm", "shelly_1l", "shelly_2", "shelly_2_5", "shelly_4pro", "shelly_uni",
"shelly_i3",
"shelly_button1", "shelly_trv",
"shelly_em", "shelly_3em",
"shelly_bulb", "shelly_bulb_rgbw", "shelly_duo", "shelly_vintage", "shelly_rgbw2", "shelly_dimmer",
"shelly_ht", "shelly_flood", "shelly_smoke", "shelly_door_window", "shelly_motion", "shelly_sense", "shelly_gas",
}
func init() {
for _, product := range gen1Products {
if err := RegisterProduct(product, newGen1Collector); err != nil {
panic(err)
}
}
}
type gen1Collector struct {
base baseCollector
client shelly.StatusClient
descriptors map[string]*prometheus.Desc
}
func newGen1Collector(config configuration.DeviceConfiguration, client shelly.StatusClient) (prometheus.Collector, error) {
if client == nil {
return nil, fmt.Errorf("client for device %q cannot be nil", config.Name)
}
c := &gen1Collector{
base: newBaseCollector(config.Name, config.Product),
client: client,
descriptors: make(map[string]*prometheus.Desc),
}
c.registerDescriptors()
return c, nil
}
func (c *gen1Collector) registerDescriptors() {
c.add("device_info", "Static Shelly device information.", "product", "mac", "firmware")
c.add("wifi_info", "Shelly Wi-Fi network information.", "ssid", "ip")
c.add("wifi_connected", "Whether the Wi-Fi station is connected (1) or not (0).")
c.add("wifi_rssi_dbm", "Received Wi-Fi signal strength in dBm.")
c.add("cloud_enabled", "Whether Shelly Cloud is enabled (1) or not (0).")
c.add("cloud_connected", "Whether the device is connected to Shelly Cloud (1) or not (0).")
c.add("mqtt_connected", "Whether the device is connected to MQTT (1) or not (0).")
c.add("update_available", "Whether a firmware update is available (1) or not (0).")
c.add("update_info", "Shelly firmware update information.", "status", "current_version", "new_version")
c.add("status_serial", "Sequence number of the Shelly status response.")
c.add("ram_size_bytes", "Total device RAM in bytes.")
c.add("ram_free_bytes", "Free device RAM in bytes.")
c.add("filesystem_size_bytes", "Total device filesystem size in bytes.")
c.add("filesystem_free_bytes", "Free device filesystem space in bytes.")
c.add("filesystem_mounted", "Whether the device data filesystem is mounted (1) or not (0).")
c.add("uptime_seconds", "Device uptime in seconds.")
c.add("relay_on", "Whether the relay output is on (1) or off (0).", "index")
c.add("relay_has_timer", "Whether a relay timer is active (1) or not (0).", "index")
c.add("relay_timer_duration_seconds", "Configured duration of the active relay timer in seconds.", "index")
c.add("relay_timer_remaining_seconds", "Remaining duration of the active relay timer in seconds.", "index")
c.add("relay_overpower", "Whether relay overpower protection is active (1) or not (0).", "index")
c.add("relay_valid", "Whether the relay status is valid (1) or not (0).", "index")
c.add("input_on", "Logical input state (1 for on, 0 for off).", "index")
c.add("input_event_info", "Latest input event and event sequence.", "index", "event", "last_sequence")
c.add("input_event_count_total", "Total input events since the device restarted.", "index")
c.add("meter_power_watts", "Current active power measured in watts.", "index")
c.add("meter_valid", "Whether the meter reading is valid (1) or not (0).", "index")
c.add("meter_timestamp_seconds", "Unix timestamp of the meter reading in seconds.", "index")
c.add("meter_overpower", "Whether the meter reports an overpower condition (1) or not (0).", "index")
c.add("meter_overpower_threshold_watts", "Configured meter overpower threshold in watts.", "index")
c.add("meter_recent_energy_watt_minutes", "Recent per-minute energy reported in watt-minutes.", "index", "minute")
c.add("meter_energy_watt_hours_total", "Total energy consumed in watt-hours, converted from the Shelly watt-minute total.", "index")
c.add("emeter_power_watts", "Current active power measured by an energy meter in watts.", "index")
c.add("emeter_reactive_power_var", "Current reactive power measured by an energy meter in var.", "index")
c.add("emeter_power_factor", "Power factor measured by an energy meter.", "index")
c.add("emeter_current_amperes", "Current measured by an energy meter in amperes.", "index")
c.add("emeter_voltage_volts", "RMS voltage measured by an energy meter in volts.", "index")
c.add("emeter_valid", "Whether the energy meter reading is valid (1) or not (0).", "index")
c.add("emeter_energy_watt_hours_total", "Total energy consumed as reported by the energy meter in watt-hours.", "index")
c.add("emeter_returned_energy_watt_hours_total", "Total energy returned to the grid in watt-hours.", "index")
c.add("total_power_watts", "Total active power across all device channels in watts.")
c.add("roller_info", "Roller operating state.", "index", "state", "stop_reason", "last_direction")
c.add("roller_power_watts", "Current roller power consumption in watts.", "index")
c.add("roller_valid", "Whether the roller power reading is valid (1) or not (0).", "index")
c.add("roller_safety_switch", "Whether the roller safety switch is active (1) or not (0).", "index")
c.add("roller_overtemperature", "Whether roller overtemperature protection is active (1) or not (0).", "index")
c.add("roller_position_percent", "Current roller position in percent.", "index")
c.add("roller_calibrating", "Whether roller calibration is running (1) or not (0).", "index")
c.add("roller_positioning", "Whether roller positioning control is available (1) or not (0).", "index")
c.add("light_info", "Light operating mode.", "index", "mode")
c.add("light_on", "Whether the light output is on (1) or off (0).", "index")
c.add("light_has_timer", "Whether a light timer is active (1) or not (0).", "index")
c.add("light_timer_remaining_seconds", "Remaining duration of the active light timer in seconds.", "index")
c.add("light_brightness_percent", "Light brightness in percent.", "index")
c.add("light_red", "Red channel value from 0 to 255.", "index")
c.add("light_green", "Green channel value from 0 to 255.", "index")
c.add("light_blue", "Blue channel value from 0 to 255.", "index")
c.add("light_white", "White channel value from 0 to 255.", "index")
c.add("light_gain_percent", "Color mode gain in percent.", "index")
c.add("light_color_temperature_kelvin", "Configured white color temperature in kelvin.", "index")
c.add("light_effect", "Selected light effect number.", "index")
c.add("temperature_celsius", "Device or sensor temperature in degrees Celsius.")
c.add("temperature_valid", "Whether the temperature reading is valid (1) or not (0).")
c.add("temperature_status_info", "Device temperature status.", "status")
c.add("overtemperature", "Whether overtemperature protection is active (1) or not (0).")
c.add("humidity_percent", "Relative humidity in percent.")
c.add("humidity_valid", "Whether the humidity reading is valid (1) or not (0).")
c.add("battery_percent", "Estimated remaining battery capacity in percent.")
c.add("battery_voltage_volts", "Measured battery voltage in volts.")
c.add("illuminance_lux", "Measured illuminance in lux.")
c.add("illuminance_valid", "Whether the illuminance reading is valid (1) or not (0).")
c.add("illuminance_info", "Classified illumination level.", "illumination")
c.add("sensor_valid", "Whether the primary sensor is valid (1) or not (0).")
c.add("sensor_error", "Product-specific sensor error code.")
c.add("connect_retries", "Number of Wi-Fi connection retries during the current wake cycle.")
c.add("motion", "Whether motion is detected (1) or not (0).")
c.add("motion_active", "Whether motion detection is active (1) or not (0).")
c.add("motion_timestamp_seconds", "Unix timestamp of the motion sensor reading.")
c.add("vibration", "Whether vibration is detected (1) or not (0).")
c.add("charger_connected", "Whether an external charger is connected (1) or not (0).")
c.add("smoke", "Whether smoke is detected (1) or not (0).")
c.add("flood", "Whether a flood condition is detected (1) or not (0).")
c.add("rain_sensor_mode", "Whether rain sensor mode is enabled (1) or not (0).")
c.add("door_window_info", "Door/window sensor state.", "state")
c.add("tilt_degrees", "Door/window sensor tilt in degrees.")
c.add("vibration_value", "Raw door/window vibration state; -1 means disabled.")
c.add("vibration_time_seconds", "Door/window vibration state validity time in seconds.")
c.add("adc_voltage_volts", "Voltage measured by an ADC input in volts.", "index")
c.add("external_temperature_celsius", "Temperature measured by an external sensor in degrees Celsius.", "index", "hardware_id")
c.add("external_humidity_percent", "Relative humidity measured by an external sensor in percent.", "index", "hardware_id")
c.add("thermostat_valve_position_percent", "Thermostatic valve position in percent.", "index")
c.add("thermostat_target_enabled", "Whether automatic target temperature control is enabled (1) or not (0).", "index")
c.add("thermostat_target_temperature_celsius", "Thermostat target temperature in degrees Celsius.", "index")
c.add("thermostat_temperature_celsius", "Temperature measured by the thermostat in degrees Celsius.", "index")
c.add("thermostat_temperature_valid", "Whether the thermostat temperature reading is valid (1) or not (0).", "index")
c.add("thermostat_schedule_enabled", "Whether the thermostat schedule is enabled (1) or not (0).", "index")
c.add("thermostat_schedule_profile", "Selected thermostat schedule profile.", "index")
c.add("thermostat_boost_minutes", "Remaining or configured thermostat boost duration in minutes.", "index")
c.add("thermostat_window_open", "Whether the thermostat reports an open window (1) or not (0).", "index")
c.add("thermostat_calibrated", "Whether the thermostatic valve is calibrated (1) or not (0).")
c.add("gas_concentration_ppm", "Measured combustible gas concentration in parts per million.")
c.add("gas_concentration_valid", "Whether the gas concentration reading is valid (1) or not (0).")
c.add("gas_sensor_info", "Gas sensor operating and alarm state.", "sensor_state", "self_test_state", "alarm_state")
c.add("gas_valve_info", "Gas valve addon state.", "index", "state")
}
func (c *gen1Collector) add(name, help string, extraLabels ...string) {
labels := append([]string{"device"}, extraLabels...)
c.descriptors[name] = prometheus.NewDesc("shelly_"+name, help, labels, nil)
}
func (c *gen1Collector) Describe(channel chan<- *prometheus.Desc) {
c.base.describe(channel)
for _, descriptor := range c.descriptors {
channel <- descriptor
}
}
func (c *gen1Collector) Collect(channel chan<- prometheus.Metric) {
started := time.Now()
var status gen1Status
err := c.client.GetStatus(context.Background(), &status)
c.base.collect(channel, err == nil, time.Since(started).Seconds())
if err != nil {
log.Warnf("Cannot collect status from Shelly device %q: %v", c.base.device, err)
return
}
c.collectCommon(channel, &status)
c.collectRelays(channel, status.Relays)
inputs := status.Inputs
if len(inputs) == 0 && status.Input != nil {
inputs = []gen1Input{{Input: status.Input}}
}
c.collectInputs(channel, inputs)
c.collectMeters(channel, status.Meters)
c.collectEMeters(channel, status.EMeters)
c.collectRollers(channel, status.Rollers)
c.collectLights(channel, status.Lights)
c.collectThermostats(channel, &status)
c.collectSensors(channel, &status)
}
func (c *gen1Collector) collectCommon(channel chan<- prometheus.Metric, status *gen1Status) {
firmware := ""
if status.Update != nil {
firmware = status.Update.OldVersion
}
c.gauge(channel, "device_info", 1, c.base.product, status.MAC, firmware)
if status.WiFiStation != nil {
c.gauge(channel, "wifi_info", 1, status.WiFiStation.SSID, status.WiFiStation.IP)
c.gauge(channel, "wifi_connected", boolFloat(status.WiFiStation.Connected))
c.gauge(channel, "wifi_rssi_dbm", float64(status.WiFiStation.RSSI))
}
if status.Cloud != nil {
c.gauge(channel, "cloud_enabled", boolFloat(status.Cloud.Enabled))
c.gauge(channel, "cloud_connected", boolFloat(status.Cloud.Connected))
}
if status.MQTT != nil {
c.gauge(channel, "mqtt_connected", boolFloat(status.MQTT.Connected))
}
if status.HasUpdate != nil || status.Update != nil {
available := status.HasUpdate != nil && *status.HasUpdate
if status.Update != nil {
available = available || status.Update.HasUpdate
}
c.gauge(channel, "update_available", boolFloat(available))
}
if status.Update != nil {
c.gauge(channel, "update_info", 1, status.Update.Status, status.Update.OldVersion, status.Update.NewVersion)
}
if status.Serial != nil {
c.gauge(channel, "status_serial", float64(*status.Serial))
}
if status.RAMTotal != nil {
c.gauge(channel, "ram_size_bytes", float64(*status.RAMTotal))
}
if status.RAMFree != nil {
c.gauge(channel, "ram_free_bytes", float64(*status.RAMFree))
}
if status.FSSize != nil {
c.gauge(channel, "filesystem_size_bytes", float64(*status.FSSize))
}
if status.FSFree != nil {
c.gauge(channel, "filesystem_free_bytes", float64(*status.FSFree))
}
if status.FSMounted != nil {
c.gauge(channel, "filesystem_mounted", boolFloat(*status.FSMounted))
}
if status.Uptime != nil {
c.gauge(channel, "uptime_seconds", *status.Uptime)
}
if status.TotalPower != nil {
c.gauge(channel, "total_power_watts", *status.TotalPower)
}
}
func (c *gen1Collector) collectRelays(channel chan<- prometheus.Metric, relays []gen1Relay) {
for index, relay := range relays {
label := strconv.Itoa(index)
c.optionalBool(channel, "relay_on", relay.On, label)
c.optionalBool(channel, "relay_has_timer", relay.HasTimer, label)
c.optionalGauge(channel, "relay_timer_duration_seconds", relay.TimerDuration, label)
c.optionalGauge(channel, "relay_timer_remaining_seconds", relay.TimerRemaining, label)
c.optionalBool(channel, "relay_overpower", relay.Overpower, label)
c.optionalBool(channel, "relay_valid", relay.Valid, label)
}
}
func (c *gen1Collector) collectInputs(channel chan<- prometheus.Metric, inputs []gen1Input) {
for index, input := range inputs {
label := strconv.Itoa(index)
c.optionalGauge(channel, "input_on", input.Input, label)
if input.Event != "" || input.LastSequence != "" {
c.gauge(channel, "input_event_info", 1, label, input.Event, input.LastSequence)
}
if input.EventCount != nil && *input.EventCount >= 0 {
c.counter(channel, "input_event_count_total", *input.EventCount, label)
}
}
}
func (c *gen1Collector) collectMeters(channel chan<- prometheus.Metric, meters []gen1Meter) {
for index, meter := range meters {
label := strconv.Itoa(index)
c.optionalGauge(channel, "meter_power_watts", meter.Power, label)
c.optionalBool(channel, "meter_valid", meter.Valid, label)
c.optionalGauge(channel, "meter_timestamp_seconds", meter.Timestamp, label)
if meter.Overpower != nil {
c.optionalGauge(channel, "meter_overpower_threshold_watts", meter.Overpower.Number, label)
c.optionalBool(channel, "meter_overpower", meter.Overpower.Bool, label)
}
if meter.Total != nil && *meter.Total >= 0 {
c.counter(channel, "meter_energy_watt_hours_total", *meter.Total/60, label)
}
for minute, recentEnergy := range meter.Counters {
c.gauge(channel, "meter_recent_energy_watt_minutes", recentEnergy, label, strconv.Itoa(minute))
}
}
}
func (c *gen1Collector) collectEMeters(channel chan<- prometheus.Metric, meters []gen1EMeter) {
for index, meter := range meters {
label := strconv.Itoa(index)
c.optionalGauge(channel, "emeter_power_watts", meter.Power, label)
c.optionalGauge(channel, "emeter_reactive_power_var", meter.ReactivePower, label)
c.optionalGauge(channel, "emeter_power_factor", meter.PowerFactor, label)
c.optionalGauge(channel, "emeter_current_amperes", meter.Current, label)
c.optionalGauge(channel, "emeter_voltage_volts", meter.Voltage, label)
c.optionalBool(channel, "emeter_valid", meter.Valid, label)
if meter.Total != nil && *meter.Total >= 0 {
c.counter(channel, "emeter_energy_watt_hours_total", *meter.Total, label)
}
if meter.TotalReturned != nil && *meter.TotalReturned >= 0 {
c.counter(channel, "emeter_returned_energy_watt_hours_total", *meter.TotalReturned, label)
}
}
}
func (c *gen1Collector) collectRollers(channel chan<- prometheus.Metric, rollers []gen1Roller) {
for index, roller := range rollers {
label := strconv.Itoa(index)
if roller.State != "" || roller.StopReason != "" || roller.LastDirection != "" {
c.gauge(channel, "roller_info", 1, label, roller.State, roller.StopReason, roller.LastDirection)
}
c.optionalGauge(channel, "roller_power_watts", roller.Power, label)
c.optionalBool(channel, "roller_valid", roller.Valid, label)
c.optionalBool(channel, "roller_safety_switch", roller.SafetySwitch, label)
c.optionalBool(channel, "roller_overtemperature", roller.Overtemperature, label)
c.optionalGauge(channel, "roller_position_percent", roller.CurrentPosition, label)
c.optionalBool(channel, "roller_calibrating", roller.Calibrating, label)
c.optionalBool(channel, "roller_positioning", roller.Positioning, label)
}
}
func (c *gen1Collector) collectLights(channel chan<- prometheus.Metric, lights []gen1Light) {
for index, light := range lights {
label := strconv.Itoa(index)
if light.Mode != "" {
c.gauge(channel, "light_info", 1, label, light.Mode)
}
c.optionalBool(channel, "light_on", light.On, label)
c.optionalBool(channel, "light_has_timer", light.HasTimer, label)
c.optionalGauge(channel, "light_timer_remaining_seconds", light.TimerRemaining, label)
c.optionalGauge(channel, "light_brightness_percent", light.Brightness, label)
c.optionalGauge(channel, "light_red", light.Red, label)
c.optionalGauge(channel, "light_green", light.Green, label)
c.optionalGauge(channel, "light_blue", light.Blue, label)
c.optionalGauge(channel, "light_white", light.White, label)
c.optionalGauge(channel, "light_gain_percent", light.Gain, label)
c.optionalGauge(channel, "light_color_temperature_kelvin", light.Temperature, label)
c.optionalGauge(channel, "light_effect", light.Effect, label)
}
}
func (c *gen1Collector) collectThermostats(channel chan<- prometheus.Metric, status *gen1Status) {
if len(status.Thermostats) > 0 {
c.optionalBool(channel, "thermostat_calibrated", status.Calibrated)
}
for index, thermostat := range status.Thermostats {
label := strconv.Itoa(index)
c.optionalGauge(channel, "thermostat_valve_position_percent", thermostat.Position, label)
if thermostat.Target != nil {
c.optionalBool(channel, "thermostat_target_enabled", thermostat.Target.Enabled, label)
if thermostat.Target.Units == "C" {
c.optionalGauge(channel, "thermostat_target_temperature_celsius", thermostat.Target.Value, label)
}
}
if thermostat.Temperature != nil {
temperature := thermostat.Temperature.C
if temperature == nil && thermostat.Temperature.Units == "C" {
temperature = thermostat.Temperature.Value
}
c.optionalGauge(channel, "thermostat_temperature_celsius", temperature, label)
c.optionalBool(channel, "thermostat_temperature_valid", thermostat.Temperature.Valid, label)
}
c.optionalBool(channel, "thermostat_schedule_enabled", thermostat.Schedule, label)
c.optionalGauge(channel, "thermostat_schedule_profile", thermostat.ScheduleProfile, label)
c.optionalGauge(channel, "thermostat_boost_minutes", thermostat.BoostMinutes, label)
c.optionalBool(channel, "thermostat_window_open", thermostat.WindowOpen, label)
}
}
func (c *gen1Collector) collectSensors(channel chan<- prometheus.Metric, status *gen1Status) {
temperature := status.Temperature
if temperature == nil && status.TemperatureSensor != nil {
temperature = status.TemperatureSensor.C
if temperature == nil && status.TemperatureSensor.Units == "C" {
temperature = status.TemperatureSensor.Value
}
}
c.optionalGauge(channel, "temperature_celsius", temperature)
if status.TemperatureSensor != nil {
c.optionalBool(channel, "temperature_valid", status.TemperatureSensor.Valid)
}
if status.TemperatureStatus != "" {
c.gauge(channel, "temperature_status_info", 1, status.TemperatureStatus)
}
c.optionalBool(channel, "overtemperature", status.Overtemperature)
if status.Humidity != nil {
c.optionalGauge(channel, "humidity_percent", status.Humidity.Value)
c.optionalBool(channel, "humidity_valid", status.Humidity.Valid)
}
if status.Battery != nil {
c.optionalGauge(channel, "battery_percent", status.Battery.Value)
c.optionalGauge(channel, "battery_voltage_volts", status.Battery.Voltage)
}
if status.Lux != nil {
c.optionalGauge(channel, "illuminance_lux", status.Lux.Value)
c.optionalBool(channel, "illuminance_valid", status.Lux.Valid)
if status.Lux.Illumination != "" {
c.gauge(channel, "illuminance_info", 1, status.Lux.Illumination)
}
}
c.optionalGauge(channel, "sensor_error", status.SensorError)
c.optionalGauge(channel, "connect_retries", status.ConnectRetries)
c.optionalBool(channel, "charger_connected", status.Charger)
c.optionalBool(channel, "smoke", status.Smoke)
c.optionalBool(channel, "flood", status.Flood)
c.optionalBool(channel, "rain_sensor_mode", status.RainSensor)
motion := status.Motion
sensorValid := status.Valid
if status.Sensor != nil {
if status.Sensor.Motion != nil {
motion = status.Sensor.Motion
}
if status.Sensor.Valid != nil {
sensorValid = status.Sensor.Valid
}
c.optionalBool(channel, "motion_active", status.Sensor.Active)
c.optionalGauge(channel, "motion_timestamp_seconds", status.Sensor.Timestamp)
c.optionalBool(channel, "vibration", status.Sensor.Vibration)
if status.Sensor.State != "" {
c.gauge(channel, "door_window_info", 1, status.Sensor.State)
}
}
c.optionalBool(channel, "motion", motion)
c.optionalBool(channel, "sensor_valid", sensorValid)
if status.Acceleration != nil {
c.optionalGauge(channel, "tilt_degrees", status.Acceleration.Tilt)
c.optionalGauge(channel, "vibration_value", status.Acceleration.Vibration)
c.optionalGauge(channel, "vibration_time_seconds", status.Acceleration.VibrationTime)
}
for index, adc := range status.ADCs {
c.optionalGauge(channel, "adc_voltage_volts", adc.Voltage, strconv.Itoa(index))
}
for _, index := range sortedKeys(status.ExternalTemperature) {
sensor := status.ExternalTemperature[index]
c.optionalGauge(channel, "external_temperature_celsius", sensor.C, index, sensor.HardwareID)
}
for _, index := range sortedKeys(status.ExternalHumidity) {
sensor := status.ExternalHumidity[index]
c.optionalGauge(channel, "external_humidity_percent", sensor.Humidity, index, sensor.HardwareID)
}
if status.GasSensor != nil {
c.gauge(channel, "gas_sensor_info", 1, status.GasSensor.SensorState, status.GasSensor.SelfTestState, status.GasSensor.AlarmState)
}
if status.Concentration != nil {
c.optionalGauge(channel, "gas_concentration_ppm", status.Concentration.PPM)
c.optionalBool(channel, "gas_concentration_valid", status.Concentration.Valid)
}
for index, valve := range status.Valves {
c.gauge(channel, "gas_valve_info", 1, strconv.Itoa(index), valve.State)
}
}
func (c *gen1Collector) gauge(channel chan<- prometheus.Metric, name string, value float64, labels ...string) {
c.emit(channel, name, prometheus.GaugeValue, value, labels...)
}
func (c *gen1Collector) counter(channel chan<- prometheus.Metric, name string, value float64, labels ...string) {
c.emit(channel, name, prometheus.CounterValue, value, labels...)
}
func (c *gen1Collector) emit(channel chan<- prometheus.Metric, name string, valueType prometheus.ValueType, value float64, labels ...string) {
allLabels := append([]string{c.base.device}, labels...)
channel <- prometheus.MustNewConstMetric(c.descriptors[name], valueType, value, allLabels...)
}
func (c *gen1Collector) optionalGauge(channel chan<- prometheus.Metric, name string, value *float64, labels ...string) {
if value != nil {
c.gauge(channel, name, *value, labels...)
}
}
func (c *gen1Collector) optionalBool(channel chan<- prometheus.Metric, name string, value *bool, labels ...string) {
if value != nil {
c.gauge(channel, name, boolFloat(*value), labels...)
}
}
func boolFloat(value bool) float64 {
if value {
return 1
}
return 0
}
func sortedKeys[T any](values map[string]T) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
+196
View File
@@ -0,0 +1,196 @@
package collector
import (
"context"
"encoding/json"
"math"
"slices"
"testing"
"github.com/prometheus/client_golang/prometheus"
dto "github.com/prometheus/client_model/go"
"lostak.dev/shelly-exporter/configuration"
)
func TestSupportedGen1ProductsAreRegistered(t *testing.T) {
supported := SupportedProducts()
for _, product := range gen1Products {
if !slices.Contains(supported, product) {
t.Errorf("product %q is not registered", product)
}
}
}
func TestProductNamesAreNormalized(t *testing.T) {
deviceCollector, err := NewDeviceCollector(configuration.DeviceConfiguration{
Name: "roller", Product: "Shelly 2.5",
}, jsonStatusClient(`{"relays":[]}`))
if err != nil {
t.Fatalf("NewDeviceCollector() returned an unexpected error: %v", err)
}
if deviceCollector == nil {
t.Fatal("NewDeviceCollector() returned a nil collector")
}
}
func TestRelayAndRollerMetrics(t *testing.T) {
status := `{
"relays":[{"ison":true,"has_timer":true,"timer_duration":30,"timer_remaining":12,"overpower":false,"is_valid":true}],
"inputs":[{"input":1,"event":"S","event_cnt":9,"last_sequence":"SS"}],
"meters":[{"power":321.5,"overpower":1800,"is_valid":true,"timestamp":1700000000,"total":600}],
"rollers":[{"state":"stop","power":0,"is_valid":true,"safety_switch":false,"overtemperature":false,"stop_reason":"normal","last_direction":"open","current_pos":90,"calibrating":false,"positioning":true}]
}`
families := gatherGen1(t, "Shelly 2.5", status)
assertGauge(t, families, "shelly_relay_on", 1)
assertGauge(t, families, "shelly_input_on", 1)
assertCounter(t, families, "shelly_input_event_count_total", 9)
assertGauge(t, families, "shelly_meter_overpower_threshold_watts", 1800)
assertCounter(t, families, "shelly_meter_energy_watt_hours_total", 10)
assertGauge(t, families, "shelly_roller_position_percent", 90)
}
func TestEMAnd3EMMetricsUseNativeWattHourCounters(t *testing.T) {
status := `{
"emeters":[
{"power":100,"reactive":8,"voltage":230,"is_valid":true,"total":1234,"total_returned":12},
{"power":200,"pf":0.98,"current":0.87,"voltage":231,"is_valid":true,"total":2345,"total_returned":23}
],
"total_power":300,"fs_mounted":true
}`
families := gatherGen1(t, "shelly_3em", status)
energy := findMetricFamily(t, families, "shelly_emeter_energy_watt_hours_total")
if energy.GetType() != dto.MetricType_COUNTER || len(energy.Metric) != 2 {
t.Fatalf("emeter energy type/count = %s/%d, want COUNTER/2", energy.GetType(), len(energy.Metric))
}
assertMetricValue(t, energy.Metric[0].GetCounter().GetValue(), 1234, "first emeter energy")
assertCounter(t, families, "shelly_emeter_returned_energy_watt_hours_total", 12)
assertGauge(t, families, "shelly_emeter_reactive_power_var", 8)
assertGauge(t, families, "shelly_total_power_watts", 300)
assertGauge(t, families, "shelly_filesystem_mounted", 1)
}
func TestLightMetrics(t *testing.T) {
status := `{
"lights":[{"ison":true,"has_timer":false,"mode":"color","brightness":80,"red":255,"green":127,"blue":10,"white":0,"gain":90,"temp":4000,"effect":2}],
"meters":[{"power":9.5,"is_valid":true}]
}`
families := gatherGen1(t, "shelly_rgbw2", status)
assertGauge(t, families, "shelly_light_on", 1)
assertGauge(t, families, "shelly_light_brightness_percent", 80)
assertGauge(t, families, "shelly_light_red", 255)
assertGauge(t, families, "shelly_light_color_temperature_kelvin", 4000)
assertGauge(t, families, "shelly_meter_power_watts", 9.5)
if hasMetricFamily(families, "shelly_meter_energy_watt_hours_total") {
t.Fatal("collector emitted an energy counter although meters[].total was absent")
}
}
func TestEnvironmentalAndDoorWindowMetrics(t *testing.T) {
status := `{
"is_valid":true,
"tmp":{"value":24.3,"units":"C","tC":24.3,"tF":75.74,"is_valid":true},
"hum":{"value":57,"is_valid":true},
"lux":{"value":12,"illumination":"dark","is_valid":true},
"accel":{"tilt":15,"vibration":1,"vibration_time":60},
"sensor":{"state":"open","is_valid":true},
"bat":{"value":71,"voltage":2.73},
"sensor_error":0
}`
families := gatherGen1(t, "shelly_door_window", status)
assertGauge(t, families, "shelly_temperature_celsius", 24.3)
assertGauge(t, families, "shelly_humidity_percent", 57)
assertGauge(t, families, "shelly_illuminance_lux", 12)
assertGauge(t, families, "shelly_tilt_degrees", 15)
assertGauge(t, families, "shelly_vibration_value", 1)
assertGauge(t, families, "shelly_battery_percent", 71)
assertGauge(t, families, "shelly_sensor_valid", 1)
}
func TestGasMetrics(t *testing.T) {
status := `{
"gas_sensor":{"sensor_state":"normal","self_test_state":"completed","alarm_state":"none"},
"concentration":{"ppm":100,"is_valid":true},
"valves":[{"state":"not_connected"}]
}`
families := gatherGen1(t, "shelly_gas", status)
assertGauge(t, families, "shelly_gas_concentration_ppm", 100)
assertGauge(t, families, "shelly_gas_concentration_valid", 1)
if !hasMetricFamily(families, "shelly_gas_sensor_info") || !hasMetricFamily(families, "shelly_gas_valve_info") {
t.Fatal("gas info metrics are missing")
}
}
func TestTRVMetrics(t *testing.T) {
status := `{
"thermostats":[{
"pos":23,
"target_t":{"enabled":true,"value":21.5,"units":"C"},
"tmp":{"value":19.4,"units":"C","is_valid":true},
"schedule":true,"schedule_profile":2,"boost_minutes":5,"window_open":false
}],
"calibrated":true,
"bat":{"value":82,"voltage":3.127},
"charger":false
}`
families := gatherGen1(t, "shelly_trv", status)
assertGauge(t, families, "shelly_thermostat_valve_position_percent", 23)
assertGauge(t, families, "shelly_thermostat_target_temperature_celsius", 21.5)
assertGauge(t, families, "shelly_thermostat_temperature_celsius", 19.4)
assertGauge(t, families, "shelly_thermostat_schedule_enabled", 1)
assertGauge(t, families, "shelly_thermostat_calibrated", 1)
assertGauge(t, families, "shelly_battery_percent", 82)
}
func jsonStatusClient(status string) statusClientFunc {
return func(_ context.Context, destination any) error {
return json.Unmarshal([]byte(status), destination)
}
}
func gatherGen1(t *testing.T, product, status string) []*dto.MetricFamily {
t.Helper()
deviceCollector, err := NewDeviceCollector(configuration.DeviceConfiguration{
Name: "test-device", Product: product,
}, jsonStatusClient(status))
if err != nil {
t.Fatalf("NewDeviceCollector() returned an unexpected error: %v", err)
}
registry := prometheus.NewPedanticRegistry()
if err := registry.Register(deviceCollector); err != nil {
t.Fatalf("Register() returned an unexpected error: %v", err)
}
families, err := registry.Gather()
if err != nil {
t.Fatalf("Gather() returned an unexpected error: %v", err)
}
return families
}
func assertCounter(t *testing.T, families []*dto.MetricFamily, name string, want float64) {
t.Helper()
family := findMetricFamily(t, families, name)
if family.GetType() != dto.MetricType_COUNTER {
t.Fatalf("%s type = %s, want COUNTER", name, family.GetType())
}
assertMetricValue(t, family.Metric[0].GetCounter().GetValue(), want, name)
}
func assertMetricValue(t *testing.T, got, want float64, name string) {
t.Helper()
if math.Abs(got-want) > 0.000001 {
t.Fatalf("%s = %f, want %f", name, got, want)
}
}
func hasMetricFamily(families []*dto.MetricFamily, name string) bool {
for _, family := range families {
if family.GetName() == name {
return true
}
}
return false
}
+243
View File
@@ -0,0 +1,243 @@
package collector
import (
"bytes"
"encoding/json"
)
// gen1Status is a superset of the optional blocks returned by Gen1 /status
// endpoints. Pointers distinguish a missing capability from a valid zero value.
type gen1Status struct {
WiFiStation *struct {
Connected bool `json:"connected"`
SSID string `json:"ssid"`
IP string `json:"ip"`
RSSI int `json:"rssi"`
} `json:"wifi_sta"`
Cloud *struct {
Enabled bool `json:"enabled"`
Connected bool `json:"connected"`
} `json:"cloud"`
MQTT *struct {
Connected bool `json:"connected"`
} `json:"mqtt"`
Serial *uint64 `json:"serial"`
HasUpdate *bool `json:"has_update"`
MAC string `json:"mac"`
Update *struct {
Status string `json:"status"`
HasUpdate bool `json:"has_update"`
NewVersion string `json:"new_version"`
OldVersion string `json:"old_version"`
} `json:"update"`
RAMTotal *uint64 `json:"ram_total"`
RAMFree *uint64 `json:"ram_free"`
FSSize *uint64 `json:"fs_size"`
FSFree *uint64 `json:"fs_free"`
Uptime *float64 `json:"uptime"`
Relays []gen1Relay `json:"relays"`
Meters []gen1Meter `json:"meters"`
EMeters []gen1EMeter `json:"emeters"`
Inputs []gen1Input `json:"inputs"`
Input *float64 `json:"input"`
Rollers []gen1Roller `json:"rollers"`
Lights []gen1Light `json:"lights"`
ADCs []gen1ADC `json:"adcs"`
Thermostats []gen1Thermostat `json:"thermostats"`
Calibrated *bool `json:"calibrated"`
Temperature *float64 `json:"temperature"`
Overtemperature *bool `json:"overtemperature"`
TemperatureStatus string `json:"temperature_status"`
TemperatureSensor *gen1TemperatureSensor `json:"tmp"`
Humidity *gen1ValueSensor `json:"hum"`
Battery *gen1Battery `json:"bat"`
Lux *gen1Lux `json:"lux"`
Sensor *gen1MotionSensor `json:"sensor"`
Acceleration *gen1Acceleration `json:"accel"`
Motion *bool `json:"motion"`
Charger *bool `json:"charger"`
Smoke *bool `json:"smoke"`
Flood *bool `json:"flood"`
RainSensor *bool `json:"rain_sensor"`
Valid *bool `json:"is_valid"`
SensorError *float64 `json:"sensor_error"`
ConnectRetries *float64 `json:"connect_retries"`
ExternalTemperature map[string]gen1ExternalTemperature `json:"ext_temperature"`
ExternalHumidity map[string]gen1ExternalHumidity `json:"ext_humidity"`
TotalPower *float64 `json:"total_power"`
FSMounted *bool `json:"fs_mounted"`
GasSensor *struct {
SensorState string `json:"sensor_state"`
SelfTestState string `json:"self_test_state"`
AlarmState string `json:"alarm_state"`
} `json:"gas_sensor"`
Concentration *struct {
PPM *float64 `json:"ppm"`
Valid *bool `json:"is_valid"`
} `json:"concentration"`
Valves []struct {
State string `json:"state"`
} `json:"valves"`
}
type gen1Relay struct {
On *bool `json:"ison"`
HasTimer *bool `json:"has_timer"`
TimerDuration *float64 `json:"timer_duration"`
TimerRemaining *float64 `json:"timer_remaining"`
Overpower *bool `json:"overpower"`
Valid *bool `json:"is_valid"`
}
type gen1Meter struct {
Power *float64 `json:"power"`
Overpower *numberOrBool `json:"overpower"`
Valid *bool `json:"is_valid"`
Timestamp *float64 `json:"timestamp"`
Counters []float64 `json:"counters"`
Total *float64 `json:"total"`
}
type gen1EMeter struct {
Power *float64 `json:"power"`
ReactivePower *float64 `json:"reactive"`
PowerFactor *float64 `json:"pf"`
Current *float64 `json:"current"`
Voltage *float64 `json:"voltage"`
Valid *bool `json:"is_valid"`
Total *float64 `json:"total"`
TotalReturned *float64 `json:"total_returned"`
}
type gen1Input struct {
Input *float64 `json:"input"`
Event string `json:"event"`
EventCount *float64 `json:"event_cnt"`
LastSequence string `json:"last_sequence"`
}
type gen1Roller struct {
State string `json:"state"`
Power *float64 `json:"power"`
Valid *bool `json:"is_valid"`
SafetySwitch *bool `json:"safety_switch"`
Overtemperature *bool `json:"overtemperature"`
StopReason string `json:"stop_reason"`
LastDirection string `json:"last_direction"`
CurrentPosition *float64 `json:"current_pos"`
Calibrating *bool `json:"calibrating"`
Positioning *bool `json:"positioning"`
}
type gen1Light struct {
On *bool `json:"ison"`
HasTimer *bool `json:"has_timer"`
TimerRemaining *float64 `json:"timer_remaining"`
Mode string `json:"mode"`
Brightness *float64 `json:"brightness"`
Red *float64 `json:"red"`
Green *float64 `json:"green"`
Blue *float64 `json:"blue"`
White *float64 `json:"white"`
Gain *float64 `json:"gain"`
Temperature *float64 `json:"temp"`
Effect *float64 `json:"effect"`
}
type gen1ADC struct {
Voltage *float64 `json:"voltage"`
}
type gen1Thermostat struct {
Position *float64 `json:"pos"`
Target *struct {
Enabled *bool `json:"enabled"`
Value *float64 `json:"value"`
Units string `json:"units"`
} `json:"target_t"`
Temperature *gen1TemperatureSensor `json:"tmp"`
Schedule *bool `json:"schedule"`
ScheduleProfile *float64 `json:"schedule_profile"`
BoostMinutes *float64 `json:"boost_minutes"`
WindowOpen *bool `json:"window_open"`
}
type gen1TemperatureSensor struct {
Value *float64 `json:"value"`
Units string `json:"units"`
C *float64 `json:"tC"`
F *float64 `json:"tF"`
Valid *bool `json:"is_valid"`
}
type gen1ValueSensor struct {
Value *float64 `json:"value"`
Valid *bool `json:"is_valid"`
}
type gen1Battery struct {
Value *float64 `json:"value"`
Voltage *float64 `json:"voltage"`
}
type gen1Lux struct {
Value *float64 `json:"value"`
Illumination string `json:"illumination"`
Valid *bool `json:"is_valid"`
}
type gen1MotionSensor struct {
Motion *bool `json:"motion"`
Vibration *bool `json:"vibration"`
Timestamp *float64 `json:"timestamp"`
Active *bool `json:"active"`
Valid *bool `json:"is_valid"`
State string `json:"state"`
}
type gen1Acceleration struct {
Tilt *float64 `json:"tilt"`
Vibration *float64 `json:"vibration"`
VibrationTime *float64 `json:"vibration_time"`
}
type gen1ExternalTemperature struct {
HardwareID string `json:"hwID"`
C *float64 `json:"tC"`
F *float64 `json:"tF"`
}
type gen1ExternalHumidity struct {
HardwareID string `json:"hwID"`
Humidity *float64 `json:"hum"`
}
// numberOrBool handles the inconsistent Gen1 "overpower" representation:
// depending on the product it is either a threshold in watts or a boolean state.
type numberOrBool struct {
Number *float64
Bool *bool
}
func (value *numberOrBool) UnmarshalJSON(data []byte) error {
data = bytes.TrimSpace(data)
if bytes.Equal(data, []byte("null")) {
return nil
}
var boolean bool
if err := json.Unmarshal(data, &boolean); err == nil {
value.Bool = &boolean
return nil
}
var number float64
if err := json.Unmarshal(data, &number); err == nil {
value.Number = &number
return nil
}
// Unknown representations should not make every other metric unavailable.
return nil
}
+31
View File
@@ -0,0 +1,31 @@
package collector
import (
"sync"
"github.com/prometheus/client_golang/prometheus"
)
// Group registers multiple device collectors as one Prometheus collector.
// Collecting devices concurrently prevents one slow device from delaying all others.
type Group []prometheus.Collector
// Describe forwards descriptors from every registered device collector.
func (group Group) Describe(channel chan<- *prometheus.Desc) {
for _, deviceCollector := range group {
deviceCollector.Describe(channel)
}
}
// Collect scrapes all configured devices concurrently.
func (group Group) Collect(channel chan<- prometheus.Metric) {
var waitGroup sync.WaitGroup
waitGroup.Add(len(group))
for _, deviceCollector := range group {
go func(current prometheus.Collector) {
defer waitGroup.Done()
current.Collect(channel)
}(deviceCollector)
}
waitGroup.Wait()
}
+140
View File
@@ -0,0 +1,140 @@
package collector
import (
"context"
"encoding/json"
"errors"
"math"
"testing"
"github.com/prometheus/client_golang/prometheus"
dto "github.com/prometheus/client_model/go"
"lostak.dev/shelly-exporter/configuration"
)
const plugSStatusFixture = `{
"wifi_sta":{"connected":true,"ssid":"lostak","ip":"192.168.0.30","rssi":-77},
"cloud":{"enabled":false,"connected":false},
"mqtt":{"connected":false},
"time":"23:52","serial":1,"has_update":false,"mac":"E8DB84BC666A",
"relays":[{"ison":true,"has_timer":false,"overpower":false}],
"meters":[{"power":166.00,"is_valid":true,"timestamp":1787010749,"counters":[168.129,167.929,167.771],"total":4188430}],
"temperature":49.40,"overtemperature":false,
"update":{"status":"unknown","has_update":false,"new_version":"","old_version":"20190516-073020/master@ea1b23db"},
"ram_total":50832,"ram_free":40188,"fs_size":233681,"fs_free":171182,"uptime":1512798
}`
type statusClientFunc func(context.Context, any) error
func (function statusClientFunc) GetStatus(ctx context.Context, destination any) error {
return function(ctx, destination)
}
func fixtureClient(_ context.Context, destination any) error {
return json.Unmarshal([]byte(plugSStatusFixture), destination)
}
func TestPlugSCollectorExposesStatusAndEnergyCounter(t *testing.T) {
deviceCollector, err := NewDeviceCollector(configuration.DeviceConfiguration{
Name: "office", Product: "plug_s",
}, statusClientFunc(fixtureClient))
if err != nil {
t.Fatal(err)
}
registry := prometheus.NewPedanticRegistry()
if err := registry.Register(deviceCollector); err != nil {
t.Fatal(err)
}
families, err := registry.Gather()
if err != nil {
t.Fatalf("Gather() returned an unexpected error: %v", err)
}
energy := findMetricFamily(t, families, "shelly_meter_energy_watt_hours_total")
if energy.GetType() != dto.MetricType_COUNTER {
t.Fatalf("energy metric type = %s, want COUNTER", energy.GetType())
}
value := energy.Metric[0].GetCounter().GetValue()
want := 4188430.0 / 60.0
if math.Abs(value-want) > 0.000001 {
t.Fatalf("energy counter = %f, want %f", value, want)
}
assertGauge(t, families, "shelly_up", 1)
assertGauge(t, families, "shelly_meter_power_watts", 166)
assertGauge(t, families, "shelly_temperature_celsius", 49.4)
assertGauge(t, families, "shelly_relay_on", 1)
}
func TestPlugSCollectorReportsFailedScrapes(t *testing.T) {
client := statusClientFunc(func(context.Context, any) error { return errors.New("unreachable") })
deviceCollector, err := NewDeviceCollector(configuration.DeviceConfiguration{
Name: "office", Product: "plug-s",
}, client)
if err != nil {
t.Fatal(err)
}
registry := prometheus.NewRegistry()
if err := registry.Register(deviceCollector); err != nil {
t.Fatal(err)
}
for expectedErrors := 1.0; expectedErrors <= 2; expectedErrors++ {
families, err := registry.Gather()
if err != nil {
t.Fatal(err)
}
assertGauge(t, families, "shelly_up", 0)
errorsFamily := findMetricFamily(t, families, "shelly_scrape_errors_total")
if value := errorsFamily.Metric[0].GetCounter().GetValue(); value != expectedErrors {
t.Fatalf("scrape errors = %f, want %f", value, expectedErrors)
}
}
}
func TestGroupAllowsMultipleDevicesWithTheSameMetrics(t *testing.T) {
group := make(Group, 0, 2)
for _, name := range []string{"office", "kitchen"} {
deviceCollector, err := NewDeviceCollector(configuration.DeviceConfiguration{
Name: name, Product: "plug_s",
}, statusClientFunc(fixtureClient))
if err != nil {
t.Fatal(err)
}
group = append(group, deviceCollector)
}
registry := prometheus.NewPedanticRegistry()
if err := registry.Register(group); err != nil {
t.Fatalf("Register() returned an unexpected error: %v", err)
}
families, err := registry.Gather()
if err != nil {
t.Fatalf("Gather() returned an unexpected error: %v", err)
}
up := findMetricFamily(t, families, "shelly_up")
if len(up.Metric) != 2 {
t.Fatalf("shelly_up has %d metrics, want 2", len(up.Metric))
}
}
func findMetricFamily(t *testing.T, families []*dto.MetricFamily, name string) *dto.MetricFamily {
t.Helper()
for _, family := range families {
if family.GetName() == name {
return family
}
}
t.Fatalf("metric family %q not found", name)
return nil
}
func assertGauge(t *testing.T, families []*dto.MetricFamily, name string, want float64) {
t.Helper()
family := findMetricFamily(t, families, name)
if family.GetType() != dto.MetricType_GAUGE {
t.Fatalf("%s type = %s, want GAUGE", name, family.GetType())
}
if value := family.Metric[0].GetGauge().GetValue(); value != want {
t.Fatalf("%s = %f, want %f", name, value, want)
}
}