This commit is contained in:
@@ -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...)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user