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 }