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
+8
View File
@@ -0,0 +1,8 @@
.git
.gitignore
config.yaml
shelly-exporter
shelly-exporter.exe
pve-exporter
*.test
*.out
+36
View File
@@ -0,0 +1,36 @@
name: Build Docker image on push
on:
push:
branches:
- '**'
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set variables
shell: bash
run: |
echo "IMAGE_NAME=gitea.lostak.dev/lostakj/shelly-exporter" >> "$GITHUB_ENV"
echo "IMAGE_TAG=${GITHUB_SHA::7}" >> "$GITHUB_ENV"
- name: Create buildx builder
shell: bash
run: |
docker buildx create --name ci-builder --use || docker buildx use ci-builder
docker buildx inspect --bootstrap
- name: Build and push image
shell: bash
run: |
docker buildx build \
--platform linux/amd64 \
-t "${IMAGE_NAME}:${IMAGE_TAG}" \
.
+43
View File
@@ -0,0 +1,43 @@
name: Build and push Docker image on tag
on:
push:
tags:
- '*'
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set variables
shell: bash
run: |
echo "IMAGE_NAME=gitea.lostak.dev/lostakj/shelly-exporter" >> "$GITHUB_ENV"
echo "IMAGE_TAG=${GITHUB_REF_NAME}" >> "$GITHUB_ENV"
- name: Login to Gitea registry
shell: bash
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.lostak.dev -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Create buildx builder
shell: bash
run: |
docker buildx create --name ci-builder --use || docker buildx use ci-builder
docker buildx inspect --bootstrap
- name: Build and push image
shell: bash
run: |
docker buildx build \
--platform linux/amd64 \
-t "${IMAGE_NAME}:${IMAGE_TAG}" \
-t "${IMAGE_NAME}:latest" \
--push \
.
+12
View File
@@ -0,0 +1,12 @@
# Local configuration can contain device credentials.
config.yaml
# Go build and test output.
shelly-exporter
shelly-exporter.exe
*.test
*.out
# IDEs.
.idea/
.vscode/
+25
View File
@@ -0,0 +1,25 @@
FROM golang:1.22-alpine AS build
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o shelly-exporter .
FROM alpine:3.20
RUN apk add --no-cache ca-certificates \
&& addgroup -S exporter \
&& adduser -S -G exporter exporter
WORKDIR /opt/shelly-exporter
COPY --from=build /build/shelly-exporter ./shelly-exporter
COPY config.example.yaml ./config.yaml
USER exporter
EXPOSE 9090
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD wget -q -O - http://127.0.0.1:9090/-/healthy || exit 1
ENTRYPOINT ["./shelly-exporter"]
CMD ["-config", "config.yaml"]
+95
View File
@@ -0,0 +1,95 @@
# Shelly Exporter
Prometheus exporter for Shelly Gen1 Wi-Fi devices that expose monitoring data through the HTTP `/status` endpoint. It supports multiple devices, HTTP Basic Authentication, and dynamically exports only the capabilities present in each device response.
## Features
- Relays, inputs, rollers, lights, power meters, thermostats, and environmental or safety sensors.
- Correct Prometheus energy counters for both Gen1 `meters` and EM/3EM `emeters`.
- Concurrent collection from all configured devices.
- Per-device availability, request duration, and error metrics.
- Strict YAML validation, graceful shutdown, and a health endpoint.
- Generic `gen1` product type for compatible devices not explicitly listed.
## Quick start
Copy the example configuration and set the device address and credentials:
```sh
cp config.example.yaml config.yaml
```
```yaml
host: 0.0.0.0
port: 9090
logLevel: 4
shelly:
timeoutSeconds: 10
devices:
- name: office-plug
product: plug_s
url: http://192.168.0.30
username: admin
password: change-me
```
Build and start the exporter:
```sh
go test ./...
go build -o shelly-exporter .
./shelly-exporter -config config.yaml
```
The exporter exposes:
- Prometheus metrics at `http://localhost:9090/metrics`
- Process health at `http://localhost:9090/-/healthy`
## Supported products
| Family | Product identifiers |
| --- | --- |
| Generic Gen1 | `gen1` |
| Plugs | `plug`, `plug_s` |
| Relays and inputs | `shelly_1`, `shelly_1pm`, `shelly_1l`, `shelly_2`, `shelly_2_5`, `shelly_4pro`, `shelly_uni`, `shelly_i3`, `shelly_button1` |
| Energy meters | `shelly_em`, `shelly_3em` |
| Lights and dimmers | `shelly_bulb`, `shelly_bulb_rgbw`, `shelly_duo`, `shelly_vintage`, `shelly_rgbw2`, `shelly_dimmer` |
| Sensors and heating | `shelly_ht`, `shelly_flood`, `shelly_smoke`, `shelly_door_window`, `shelly_motion`, `shelly_sense`, `shelly_gas`, `shelly_trv` |
Product identifiers are case-insensitive and punctuation is normalized. For example, `Shelly 2.5` resolves to `shelly_2_5`, and `plug-s` resolves to `plug_s`.
## Documentation
- [Documentation index](docs/README.md)
- [Configuration reference](docs/configuration.md)
- [Metrics reference](docs/metrics.md)
- [Operations and troubleshooting](docs/operations.md)
- [Architecture and adding products](docs/architecture.md)
## Prometheus configuration
```yaml
scrape_configs:
- job_name: shelly
static_configs:
- targets: [shelly-exporter:9090]
```
Every device is contacted once during each Prometheus scrape. Choose a scrape interval appropriate for the device and network. Battery-powered Gen1 sensors normally sleep and may be reachable only briefly; push events or MQTT are generally better for continuous monitoring of those devices.
## Docker
```sh
docker build --rm -t shelly-exporter:latest .
docker run --rm -p 9090:9090 \
-v "$PWD/config.yaml:/opt/shelly-exporter/config.yaml:ro" \
shelly-exporter:latest
```
## Scope
This exporter currently implements the Shelly Gen1 `/status` protocol. Shelly Plus, Pro, Gen2, and Gen3 products use an RPC API and are not currently supported by this client.
Product fields and energy units follow the [official Shelly Gen1 API documentation](https://shelly-api-docs.shelly.cloud/gen1/).
+112
View File
@@ -0,0 +1,112 @@
// Package application wires configuration, product collectors and the HTTP server together.
package application
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
log "github.com/sirupsen/logrus"
"lostak.dev/shelly-exporter/collector"
"lostak.dev/shelly-exporter/configuration"
"lostak.dev/shelly-exporter/shelly"
)
// Application is a configured Shelly exporter HTTP server.
type Application struct {
config *configuration.Configuration
registry *prometheus.Registry
server *http.Server
}
// New loads a configuration and registers one collector per Shelly device.
func New(configPath string) (*Application, error) {
config, err := configuration.Load(configPath)
if err != nil {
return nil, err
}
log.SetLevel(log.AllLevels[config.LogLevel])
registry := prometheus.NewRegistry()
deviceCollectors := make(collector.Group, 0, len(config.Shelly.Devices))
for _, device := range config.Shelly.Devices {
client, err := shelly.NewClient(
device.URL,
device.Username,
device.Password,
time.Duration(config.Shelly.TimeoutSeconds)*time.Second,
)
if err != nil {
return nil, fmt.Errorf("create client for device %q: %w", device.Name, err)
}
deviceCollector, err := collector.NewDeviceCollector(device, client)
if err != nil {
return nil, fmt.Errorf("configure device %q: %w", device.Name, err)
}
deviceCollectors = append(deviceCollectors, deviceCollector)
log.Infof("Registered %s metrics for Shelly device %q.", device.Product, device.Name)
}
if err := registry.Register(deviceCollectors); err != nil {
return nil, fmt.Errorf("register device metrics: %w", err)
}
mux := http.NewServeMux()
mux.Handle("/metrics", promhttp.HandlerFor(registry, promhttp.HandlerOpts{EnableOpenMetrics: true}))
mux.HandleFunc("/-/healthy", func(response http.ResponseWriter, _ *http.Request) {
response.WriteHeader(http.StatusOK)
_, _ = response.Write([]byte("OK\n"))
})
address := fmt.Sprintf("%s:%d", config.Host, config.Port)
return &Application{
config: config,
registry: registry,
server: &http.Server{
Addr: address,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
},
}, nil
}
// Run serves metrics until the context is cancelled or the server fails.
func (app *Application) Run(ctx context.Context) error {
listener, err := net.Listen("tcp", app.server.Addr)
if err != nil {
return fmt.Errorf("listen on %s: %w", app.server.Addr, err)
}
serverError := make(chan error, 1)
log.Infof("Shelly exporter started on %s.", listener.Addr())
go func() {
serverError <- app.server.Serve(listener)
}()
select {
case err := <-serverError:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
case <-ctx.Done():
shutdownContext, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := app.server.Shutdown(shutdownContext); err != nil {
return fmt.Errorf("shut down HTTP server: %w", err)
}
err = <-serverError
if err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
}
+40
View File
@@ -0,0 +1,40 @@
package application
import (
"context"
"os"
"path/filepath"
"testing"
)
func TestApplicationSupportsMultipleDevicesAndGracefulShutdown(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.yaml")
config := []byte(`host: 127.0.0.1
port: 9090
logLevel: 4
shelly:
timeoutSeconds: 1
devices:
- name: office
product: plug_s
url: http://192.0.2.1
- name: kitchen
product: plug_s
url: http://192.0.2.2
`)
if err := os.WriteFile(configPath, config, 0o600); err != nil {
t.Fatal(err)
}
app, err := New(configPath)
if err != nil {
t.Fatalf("New() returned an unexpected error: %v", err)
}
app.server.Addr = "127.0.0.1:0"
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := app.Run(ctx); err != nil {
t.Fatalf("Run() returned an unexpected error: %v", err)
}
}
+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)
}
}
+31
View File
@@ -0,0 +1,31 @@
# HTTP server configuration.
host: 0.0.0.0
port: 9090
# Panic = 0, Fatal = 1, Error = 2, Warn = 3,
# Info = 4, Debug = 5, Trace = 6
logLevel: 4
shelly:
# Per-device HTTP timeout. Devices are scraped concurrently.
timeoutSeconds: 10
devices:
- name: office-plug
product: plug_s
url: http://192.168.0.30
username: admin
password: change-me
# Multiple devices of the same or another registered product are supported.
# - name: main-meter
# product: shelly_3em
# url: http://192.168.0.31
# username: admin
# password: change-me
# Use gen1 for another compatible Shelly Gen1 /status response. Only fields
# actually present in the response are exported.
# - name: other-shelly
# product: gen1
# url: http://192.168.0.32
+105
View File
@@ -0,0 +1,105 @@
package configuration
import (
"errors"
"fmt"
"net"
"net/url"
"os"
"strings"
"gopkg.in/yaml.v2"
)
const defaultTimeoutSeconds = 10
// Configuration contains the HTTP server and Shelly device configuration.
type Configuration struct {
Host string `yaml:"host"`
Port uint16 `yaml:"port"`
LogLevel int `yaml:"logLevel"`
Shelly ShellyConfiguration `yaml:"shelly"`
}
// ShellyConfiguration controls communication with all configured devices.
type ShellyConfiguration struct {
TimeoutSeconds int `yaml:"timeoutSeconds"`
Devices []DeviceConfiguration `yaml:"devices"`
}
// DeviceConfiguration identifies a single Shelly device and its product implementation.
type DeviceConfiguration struct {
Name string `yaml:"name"`
Product string `yaml:"product"`
URL string `yaml:"url"`
Username string `yaml:"username"`
Password string `yaml:"password"`
}
// Load reads and strictly validates a YAML configuration file.
func Load(path string) (*Configuration, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read configuration %q: %w", path, err)
}
var config Configuration
if err := yaml.UnmarshalStrict(data, &config); err != nil {
return nil, fmt.Errorf("parse configuration %q: %w", path, err)
}
config.applyDefaults()
if err := config.Validate(); err != nil {
return nil, err
}
return &config, nil
}
func (c *Configuration) applyDefaults() {
if c.Shelly.TimeoutSeconds == 0 {
c.Shelly.TimeoutSeconds = defaultTimeoutSeconds
}
}
// Validate checks fields that are independent of a concrete product implementation.
func (c *Configuration) Validate() error {
if net.ParseIP(c.Host) == nil {
return fmt.Errorf("host is not a valid IP address: %q", c.Host)
}
if c.Port == 0 {
return errors.New("port must be greater than zero")
}
if c.LogLevel < 0 || c.LogLevel > 6 {
return errors.New("logLevel must be between 0 and 6")
}
if c.Shelly.TimeoutSeconds <= 0 {
return errors.New("shelly.timeoutSeconds must be greater than zero")
}
if len(c.Shelly.Devices) == 0 {
return errors.New("shelly.devices cannot be empty")
}
names := make(map[string]struct{}, len(c.Shelly.Devices))
for index, device := range c.Shelly.Devices {
prefix := fmt.Sprintf("shelly.devices[%d]", index)
if strings.TrimSpace(device.Name) == "" {
return fmt.Errorf("%s.name cannot be empty", prefix)
}
if _, exists := names[device.Name]; exists {
return fmt.Errorf("device name %q is configured more than once", device.Name)
}
names[device.Name] = struct{}{}
if strings.TrimSpace(device.Product) == "" {
return fmt.Errorf("%s.product cannot be empty", prefix)
}
parsedURL, err := url.ParseRequestURI(device.URL)
if err != nil || parsedURL.Host == "" || (parsedURL.Scheme != "http" && parsedURL.Scheme != "https") {
return fmt.Errorf("%s.url must be an absolute HTTP or HTTPS URL", prefix)
}
if parsedURL.RawQuery != "" || parsedURL.Fragment != "" {
return fmt.Errorf("%s.url cannot contain a query or fragment", prefix)
}
}
return nil
}
+72
View File
@@ -0,0 +1,72 @@
package configuration
import (
"os"
"path/filepath"
"strings"
"testing"
)
func validConfiguration() Configuration {
return Configuration{
Host: "0.0.0.0",
Port: 9090,
LogLevel: 4,
Shelly: ShellyConfiguration{
TimeoutSeconds: 10,
Devices: []DeviceConfiguration{
{Name: "office", Product: "plug_s", URL: "http://192.168.0.30", Username: "admin", Password: "secret"},
},
},
}
}
func TestConfigurationValidate(t *testing.T) {
config := validConfiguration()
if err := config.Validate(); err != nil {
t.Fatalf("Validate() returned an unexpected error: %v", err)
}
}
func TestConfigurationRejectsDuplicateDeviceNames(t *testing.T) {
config := validConfiguration()
config.Shelly.Devices = append(config.Shelly.Devices, config.Shelly.Devices[0])
err := config.Validate()
if err == nil || !strings.Contains(err.Error(), "configured more than once") {
t.Fatalf("Validate() error = %v, want duplicate name error", err)
}
}
func TestLoadAppliesTimeoutDefaultAndRejectsUnknownFields(t *testing.T) {
directory := t.TempDir()
validPath := filepath.Join(directory, "valid.yaml")
validYAML := []byte(`host: 0.0.0.0
port: 9090
logLevel: 4
shelly:
devices:
- name: office
product: plug_s
url: http://192.168.0.30
`)
if err := os.WriteFile(validPath, validYAML, 0o600); err != nil {
t.Fatal(err)
}
config, err := Load(validPath)
if err != nil {
t.Fatalf("Load() returned an unexpected error: %v", err)
}
if config.Shelly.TimeoutSeconds != defaultTimeoutSeconds {
t.Fatalf("TimeoutSeconds = %d, want %d", config.Shelly.TimeoutSeconds, defaultTimeoutSeconds)
}
invalidPath := filepath.Join(directory, "invalid.yaml")
if err := os.WriteFile(invalidPath, append(validYAML, []byte("unknown: true\n")...), 0o600); err != nil {
t.Fatal(err)
}
if _, err := Load(invalidPath); err == nil {
t.Fatal("Load() accepted an unknown field")
}
}
+25
View File
@@ -0,0 +1,25 @@
# Documentation
This directory contains the detailed Shelly Exporter documentation.
## User guides
- [Configuration reference](configuration.md) describes every YAML field, product identifiers, authentication, and multi-device examples.
- [Metrics reference](metrics.md) lists metric types, labels, units, optional behavior, and energy counter semantics.
- [Operations and troubleshooting](operations.md) covers deployment, Prometheus setup, example alerts and queries, security, and common failures.
## Developer guide
- [Architecture and adding products](architecture.md) explains request flow, package responsibilities, Gen1 schema extension, metric conventions, and testing.
## Protocol scope
The current implementation targets the Shelly Gen1 HTTP API:
```text
GET <device-url>/status
Authorization: Basic ... # only when credentials are configured
Accept: application/json
```
Newer Shelly generations use a different RPC API and authentication model. They require a separate protocol client in addition to new response structures and collectors.
+189
View File
@@ -0,0 +1,189 @@
# Architecture and adding products
## Design goals
The exporter separates transport, product decoding, Prometheus collection, configuration, and HTTP serving so additional Shelly models do not require changes throughout the application.
The Gen1 implementation uses a superset response structure with optional fields. This matches the Shelly API, where `/status` contains common fields plus product- and mode-specific blocks.
## Request flow
```mermaid
flowchart LR
P[Prometheus] -->|GET /metrics| H[HTTP server]
H --> R[Prometheus registry]
R --> G[Collector group]
G -->|concurrent| C1[Device collector A]
G -->|concurrent| C2[Device collector B]
C1 --> S1[Status client]
C2 --> S2[Status client]
S1 -->|GET /status| D1[Shelly device A]
S2 -->|GET /status| D2[Shelly device B]
```
There is no background polling loop or value cache. Each `/metrics` request produces one status request per configured device.
## Package responsibilities
| Package/file | Responsibility |
| --- | --- |
| [`main.go`](../main.go) | Parses `-config`, installs signal handling, and starts the application. |
| [`configuration`](../configuration/configuration.go) | Strict YAML loading, defaults, and product-independent validation. |
| [`application`](../application/application.go) | Creates clients and collectors, owns the Prometheus registry, and serves HTTP endpoints. |
| [`shelly`](../shelly/client.go) | Gen1 HTTP `/status` client, timeout, Basic Authentication, status checking, and JSON decoding. |
| [`collector/factory.go`](../collector/factory.go) | Normalizes product identifiers and maps them to collector constructors. |
| [`collector/group.go`](../collector/group.go) | Presents all device collectors as one registered collector and runs them concurrently. |
| [`collector/base.go`](../collector/base.go) | Shared `shelly_up`, request duration, and request error metrics. |
| [`collector/gen1_status.go`](../collector/gen1_status.go) | Optional superset of known Gen1 `/status` response blocks. |
| [`collector/gen1_collector.go`](../collector/gen1_collector.go) | Descriptor definitions and conversion from Gen1 status data to Prometheus metrics. |
## Startup lifecycle
1. `configuration.Load` reads and strictly parses YAML.
2. Product-independent validation checks the listener, timeout, unique names, and URLs.
3. `application.New` creates one HTTP status client per configured device.
4. `collector.NewDeviceCollector` normalizes the configured product and resolves its factory.
5. All device collectors are wrapped in `collector.Group` and registered once in a private Prometheus registry.
6. `/metrics` and `/-/healthy` handlers are installed.
7. `Application.Run` opens the listener and serves until cancellation or a server error.
A private registry prevents application metrics from being mixed with process-global collectors registered by dependencies.
## Collection behavior
`collector.Group.Collect` starts one goroutine per configured device and waits for all of them. Each Gen1 collector then:
1. calls `StatusClient.GetStatus`;
2. emits scrape health, duration, and cumulative error count;
3. stops for that device if HTTP or JSON decoding failed;
4. emits common status metrics;
5. processes relays, inputs, meters, EM meters, rollers, lights, thermostats, and sensors;
6. skips every absent optional field.
The base error counter uses an atomic integer because Prometheus can execute overlapping collections. Device-specific values are const metrics built from the latest response and do not retain mutable state.
## Why status fields use pointers
In JSON, a missing field and a present zero value have different meanings. For example:
```json
{}
```
means that a capability may not exist, while:
```json
{"power": 0}
```
means that the device supports power measurement and currently reports zero watts.
Numeric and boolean status fields therefore use pointers in `gen1Status`. Collection helpers emit a metric only for a non-nil pointer. The custom `numberOrBool` decoder handles the Gen1 `overpower` field because different product families represent it as either a boolean state or a numeric threshold.
## Metric conventions
- Use the `shelly_` namespace.
- Use Prometheus base-unit suffixes such as `_seconds`, `_bytes`, `_watts`, and `_volts`.
- Use `_total` only for counters.
- All product metrics include `device`; indexed response arrays additionally include `index`.
- Boolean values are gauges with `0` or `1`.
- Text is exposed through labels on an `_info` gauge with value `1`.
- Reuse an existing metric when a new product has the same semantic value.
- Avoid labels containing frequently changing or unbounded data.
- Preserve native cumulative device values instead of accumulating scrape deltas in exporter memory.
### Energy counter rules
Gen1 uses two different cumulative representations:
- `meters[].total` is watt-minutes and is divided by 60 before being exported as `shelly_meter_energy_watt_hours_total`.
- `emeters[].total` and `total_returned` are already Wh and are exported unchanged.
Both are Prometheus counters. Reboots or explicit device resets can decrease them; Prometheus counter functions handle the reset.
## Extending the Gen1 schema
Use this path when a product already supports `GET /status` but returns a field the generic collector does not understand.
### 1. Capture an official response fixture
Use the official Shelly documentation and, when available, a real device response. Remove passwords and private network identifiers before committing fixtures.
### 2. Add optional response fields
Extend [`gen1_status.go`](../collector/gen1_status.go). Use pointers for scalar fields:
```go
type gen1Status struct {
// Existing fields...
Voltage *float64 `json:"voltage"`
}
```
For a nested or repeated block, introduce a dedicated type rather than an anonymous map. A typed structure catches incompatible API changes during JSON decoding.
### 3. Register descriptors
Add each metric once in `registerDescriptors`:
```go
c.add("supply_voltage_volts", "Device supply voltage in volts.")
```
`add` automatically prefixes the name with `shelly_` and prepends the `device` label. Pass only additional label names.
### 4. Emit only present values
Use the existing helpers:
```go
c.optionalGauge(channel, "supply_voltage_volts", status.Voltage)
c.optionalBool(channel, "feature_enabled", status.FeatureEnabled)
```
Use `counter` only for a cumulative, monotonically increasing source value:
```go
if status.Events != nil && *status.Events >= 0 {
c.counter(channel, "events_total", *status.Events)
}
```
### 5. Register the product identifier
If the new model is known to use this schema, append its normalized identifier to `gen1Products`. Unlisted compatible devices can already use `product: gen1` without registration.
### 6. Add fixture-based tests
Add a representative JSON fixture in the collector tests and gather through `prometheus.NewPedanticRegistry`. Test:
- expected metric values and labels;
- metric type, particularly counters;
- unit conversions;
- valid zero values;
- absence of a metric when the source field is absent;
- inconsistent JSON forms if the device family has firmware variations.
Run:
```sh
gofmt -w collector
go test ./...
go vet ./...
go build ./...
```
## Adding a different protocol generation
Do not model a Gen2/Gen3 RPC response as a Gen1 `/status` response. The current `shelly.StatusClient` always appends `/status` and implements Basic Authentication, so supporting an RPC generation requires a transport extension as well as a collector.
A clean implementation should:
1. define a product-independent request/client interface or a protocol client factory;
2. choose the protocol client from product metadata during application construction;
3. implement the RPC endpoint and its authentication scheme separately;
4. add a collector with generation-appropriate response structures;
5. register product identifiers through the existing collector factory;
6. retain the shared collector group and scrape health metrics.
This keeps generation-specific endpoint paths and authentication out of the application HTTP server.
+131
View File
@@ -0,0 +1,131 @@
# Configuration reference
Shelly Exporter reads one YAML file at startup. The default path is `config.yaml`; use the command-line flag to select another file:
```sh
./shelly-exporter -config /etc/shelly-exporter/config.yaml
```
Configuration is parsed strictly. Unknown fields, invalid value types, and duplicate YAML keys accepted by neither the parser nor validation cause startup to fail instead of being silently ignored.
## Complete example
```yaml
host: 0.0.0.0
port: 9090
logLevel: 4
shelly:
timeoutSeconds: 10
devices:
- name: office-plug
product: plug_s
url: http://192.168.0.30
username: admin
password: secret
- name: main-meter
product: shelly_3em
url: http://192.168.0.31
username: admin
password: another-secret
- name: garage-relay
product: shelly_1
url: http://192.168.0.32
username: ""
password: ""
```
## Top-level fields
| Field | Required | Default | Description |
| --- | --- | --- | --- |
| `host` | yes | none | Literal IPv4 or IPv6 address on which the exporter listens. Hostnames such as `localhost` are not accepted. Use `0.0.0.0` to listen on all IPv4 interfaces. |
| `port` | yes | none | TCP listening port from 1 to 65535. |
| `logLevel` | no | `0` | Numeric Logrus severity described below. |
| `shelly` | yes | none | Shelly client and device configuration. |
### Log levels
| Value | Level |
| ---: | --- |
| `0` | Panic |
| `1` | Fatal |
| `2` | Error |
| `3` | Warn |
| `4` | Info |
| `5` | Debug |
| `6` | Trace |
`4` is a practical production default. Debug and trace logging can be useful during device communication troubleshooting.
## Shelly fields
| Field | Required | Default | Description |
| --- | --- | --- | --- |
| `shelly.timeoutSeconds` | no | `10` | Complete HTTP request timeout for each device. A value of `0` also selects the default; negative values are invalid. |
| `shelly.devices` | yes | none | Non-empty list of devices. Devices are contacted concurrently during a Prometheus scrape. |
The timeout applies independently to each device. Since devices are scraped concurrently, one unavailable device delays the scrape by at most its own timeout rather than the sum of every device timeout.
## Device fields
| Field | Required | Description |
| --- | --- | --- |
| `name` | yes | Unique stable device name. It becomes the `device` label on all metrics. Names are compared case-sensitively. |
| `product` | yes | Registered product identifier or `gen1`. Product normalization is described below. |
| `url` | yes | Absolute `http://` or `https://` base URL. The exporter appends `/status`. Queries and fragments are rejected. |
| `username` | no | HTTP Basic Authentication username. |
| `password` | no | HTTP Basic Authentication password. |
Credentials are never used as metric labels. Keep the configuration file readable only by the exporter account because it contains plaintext passwords.
The exporter sends an `Authorization: Basic` header when either `username` or `password` is non-empty. In normal configurations, provide both values or leave both empty.
### URL handling
| Configured URL | Requested status URL |
| --- | --- |
| `http://192.168.0.30` | `http://192.168.0.30/status` |
| `http://192.168.0.30/` | `http://192.168.0.30/status` |
| `https://shelly.example/device` | `https://shelly.example/device/status` |
HTTPS uses the operating system trust store and normal certificate verification. There is no option to disable TLS verification.
## Product identifiers
| Family | Identifiers |
| --- | --- |
| Generic Gen1 | `gen1` |
| Plugs | `plug`, `plug_s` |
| Relays and inputs | `shelly_1`, `shelly_1pm`, `shelly_1l`, `shelly_2`, `shelly_2_5`, `shelly_4pro`, `shelly_uni`, `shelly_i3`, `shelly_button1` |
| Energy meters | `shelly_em`, `shelly_3em` |
| Lights and dimmers | `shelly_bulb`, `shelly_bulb_rgbw`, `shelly_duo`, `shelly_vintage`, `shelly_rgbw2`, `shelly_dimmer` |
| Sensors and heating | `shelly_ht`, `shelly_flood`, `shelly_smoke`, `shelly_door_window`, `shelly_motion`, `shelly_sense`, `shelly_gas`, `shelly_trv` |
Identifiers are converted to lowercase, and each punctuation or whitespace run becomes an underscore. Examples:
| Input | Normalized identifier |
| --- | --- |
| `plug-s` | `plug_s` |
| `Shelly 2.5` | `shelly_2_5` |
| `SHELLY 3EM` | `shelly_3em` |
Use `gen1` for an unlisted product that returns a compatible Gen1 `/status` JSON response. The collector detects known optional response blocks and emits only corresponding metrics. A successful HTTP request does not guarantee that every model-specific field is understood; inspect `/metrics` and compare it with the raw device response.
## Validation failures
The exporter refuses to start when:
- `host` is not a literal IP address;
- `port` is zero or outside the YAML target type range;
- `logLevel` is outside `0..6`;
- the timeout is negative;
- the device list is empty;
- a device name is empty or duplicated;
- a product is empty or not registered;
- a URL is not absolute HTTP/HTTPS or contains a query or fragment;
- an unknown YAML field is present.
Configuration is loaded only during startup. Restart the process after editing the file.
+188
View File
@@ -0,0 +1,188 @@
# Metrics reference
Shelly Exporter exposes OpenMetrics-compatible Prometheus data on `/metrics`. A collection starts when Prometheus requests that endpoint; the exporter does not poll devices in the background.
## Conventions
- Every series has a stable `device` label taken from configuration.
- Request health metrics also have a normalized `product` label.
- Channel-based values use a zero-based `index` label matching the position in the Shelly JSON array.
- Information metrics have a constant value of `1` and place textual state in labels.
- Boolean gauges use `1` for true or active and `0` for false or inactive.
- A metric is omitted when its source field is absent. Missing metrics do not imply a zero value.
- Device credentials and URLs are not exported as labels.
## Exporter and device health
These metrics exist for every configured device, including failed scrapes.
| Metric | Type | Labels | Description |
| --- | --- | --- | --- |
| `shelly_up` | gauge | `device`, `product` | `1` when the latest `/status` request and JSON decoding succeeded; otherwise `0`. |
| `shelly_scrape_duration_seconds` | gauge | `device`, `product` | Wall-clock duration of the latest device request. |
| `shelly_scrape_errors_total` | counter | `device`, `product` | Failed requests since this exporter process started. |
On a failed request only these three metric families are emitted for the affected device. A failure from one device does not prevent concurrent devices from being collected.
## Common Gen1 status
| Metric | Type | Additional labels | Description |
| --- | --- | --- | --- |
| `shelly_device_info` | gauge | `product`, `mac`, `firmware` | Configured product and device identity. Value is always `1`. |
| `shelly_wifi_info` | gauge | `ssid`, `ip` | Connected Wi-Fi network identity. Value is always `1`. |
| `shelly_wifi_connected` | gauge | none | Wi-Fi station connection state. |
| `shelly_wifi_rssi_dbm` | gauge | none | Received Wi-Fi signal strength in dBm. |
| `shelly_cloud_enabled` | gauge | none | Shelly Cloud enabled state. |
| `shelly_cloud_connected` | gauge | none | Shelly Cloud connection state. |
| `shelly_mqtt_connected` | gauge | none | MQTT connection state. |
| `shelly_update_available` | gauge | none | Firmware update availability. |
| `shelly_update_info` | gauge | `status`, `current_version`, `new_version` | Firmware update state. Value is always `1`. |
| `shelly_status_serial` | gauge | none | Device status sequence number. |
| `shelly_ram_size_bytes` | gauge | none | Total device RAM. |
| `shelly_ram_free_bytes` | gauge | none | Available device RAM. |
| `shelly_filesystem_size_bytes` | gauge | none | Total filesystem size. |
| `shelly_filesystem_free_bytes` | gauge | none | Free filesystem space. |
| `shelly_filesystem_mounted` | gauge | none | Data filesystem mount state, reported by products such as 3EM. |
| `shelly_uptime_seconds` | gauge | none | Device uptime. |
## Relays and inputs
| Metric | Type | Additional labels | Description |
| --- | --- | --- | --- |
| `shelly_relay_on` | gauge | `index` | Relay output state. |
| `shelly_relay_has_timer` | gauge | `index` | Whether a relay timer is active. |
| `shelly_relay_timer_duration_seconds` | gauge | `index` | Configured duration of the relay timer. |
| `shelly_relay_timer_remaining_seconds` | gauge | `index` | Remaining relay timer duration. |
| `shelly_relay_overpower` | gauge | `index` | Relay overpower protection state. |
| `shelly_relay_valid` | gauge | `index` | Relay status validity when provided by the device. |
| `shelly_input_on` | gauge | `index` | Logical input state. |
| `shelly_input_event_info` | gauge | `index`, `event`, `last_sequence` | Latest button/input event. Value is always `1`. |
| `shelly_input_event_count_total` | counter | `index` | Input events since device restart. A reboot is interpreted by Prometheus as a counter reset. |
## Standard power meters
The `meters` block is used by Plug, Plug S, PM relays, dimmers, and several light products.
| Metric | Type | Additional labels | Description |
| --- | --- | --- | --- |
| `shelly_meter_power_watts` | gauge | `index` | Current active power. |
| `shelly_meter_valid` | gauge | `index` | Meter reading validity. |
| `shelly_meter_timestamp_seconds` | gauge | `index` | Unix timestamp of the latest counter reading. |
| `shelly_meter_overpower` | gauge | `index` | Boolean overpower state on products that return a boolean. |
| `shelly_meter_overpower_threshold_watts` | gauge | `index` | Overpower threshold on products that return a numeric value. |
| `shelly_meter_recent_energy_watt_minutes` | gauge | `index`, `minute` | Device-provided recent per-minute energy slots. `minute` is the zero-based array position. |
| `shelly_meter_energy_watt_hours_total` | counter | `index` | Cumulative consumed energy converted from watt-minutes to watt-hours. |
Shelly reports `meters[].total` in watt-minutes. The exporter divides the value by 60:
```text
shelly_meter_energy_watt_hours_total = meters[index].total / 60
```
For example, a device total of `4188430` watt-minutes is exported as approximately `69807.1667` Wh.
## EM and 3EM power meters
The `emeters` block uses watt-hours directly and can include returned energy.
| Metric | Type | Additional labels | Description |
| --- | --- | --- | --- |
| `shelly_emeter_power_watts` | gauge | `index` | Current active power. |
| `shelly_emeter_reactive_power_var` | gauge | `index` | Current reactive power. |
| `shelly_emeter_power_factor` | gauge | `index` | Power factor, primarily on 3EM. |
| `shelly_emeter_current_amperes` | gauge | `index` | Measured current, primarily on 3EM. |
| `shelly_emeter_voltage_volts` | gauge | `index` | RMS voltage. |
| `shelly_emeter_valid` | gauge | `index` | Energy meter validity. |
| `shelly_emeter_energy_watt_hours_total` | counter | `index` | Cumulative consumed energy in Wh. No conversion is applied. |
| `shelly_emeter_returned_energy_watt_hours_total` | counter | `index` | Cumulative energy returned to the grid in Wh. |
| `shelly_total_power_watts` | gauge | none | Sum of power over all channels when supplied by the device. |
The device can reset persisted totals through its API. Prometheus correctly treats a decrease as a counter reset when functions such as `rate()` or `increase()` are used.
## Rollers
| Metric | Type | Additional labels | Description |
| --- | --- | --- | --- |
| `shelly_roller_info` | gauge | `index`, `state`, `stop_reason`, `last_direction` | Roller textual state. Value is always `1`. |
| `shelly_roller_power_watts` | gauge | `index` | Current motor power. |
| `shelly_roller_valid` | gauge | `index` | Power meter validity. |
| `shelly_roller_safety_switch` | gauge | `index` | Safety input state. |
| `shelly_roller_overtemperature` | gauge | `index` | Roller overtemperature state. |
| `shelly_roller_position_percent` | gauge | `index` | Current position in percent. A device may use a negative value for an invalid or uncalibrated position. |
| `shelly_roller_calibrating` | gauge | `index` | Calibration in progress. |
| `shelly_roller_positioning` | gauge | `index` | Position control availability. |
## Lights and dimmers
| Metric | Type | Additional labels | Description |
| --- | --- | --- | --- |
| `shelly_light_info` | gauge | `index`, `mode` | Light operating mode. Value is always `1`. |
| `shelly_light_on` | gauge | `index` | Light output state. |
| `shelly_light_has_timer` | gauge | `index` | Light timer state. |
| `shelly_light_timer_remaining_seconds` | gauge | `index` | Remaining light timer duration. |
| `shelly_light_brightness_percent` | gauge | `index` | Brightness in percent. |
| `shelly_light_red` | gauge | `index` | Red channel, normally `0..255`. |
| `shelly_light_green` | gauge | `index` | Green channel, normally `0..255`. |
| `shelly_light_blue` | gauge | `index` | Blue channel, normally `0..255`. |
| `shelly_light_white` | gauge | `index` | White channel, normally `0..255`. |
| `shelly_light_gain_percent` | gauge | `index` | Color gain in percent. |
| `shelly_light_color_temperature_kelvin` | gauge | `index` | White color temperature. |
| `shelly_light_effect` | gauge | `index` | Selected numeric effect. |
## Environmental and safety sensors
| Metric | Type | Additional labels | Description |
| --- | --- | --- | --- |
| `shelly_temperature_celsius` | gauge | none | Internal or primary sensor temperature in °C. |
| `shelly_temperature_valid` | gauge | none | Temperature reading validity. |
| `shelly_temperature_status_info` | gauge | `status` | Textual temperature state such as `Normal`. Value is always `1`. |
| `shelly_overtemperature` | gauge | none | Device overtemperature protection state. |
| `shelly_humidity_percent` | gauge | none | Relative humidity. |
| `shelly_humidity_valid` | gauge | none | Humidity reading validity. |
| `shelly_battery_percent` | gauge | none | Estimated battery capacity. |
| `shelly_battery_voltage_volts` | gauge | none | Measured battery voltage. |
| `shelly_charger_connected` | gauge | none | External charger state. |
| `shelly_illuminance_lux` | gauge | none | Measured illuminance. |
| `shelly_illuminance_valid` | gauge | none | Illuminance reading validity. |
| `shelly_illuminance_info` | gauge | `illumination` | Classified illumination such as `dark`, `twilight`, or `bright`. |
| `shelly_sensor_valid` | gauge | none | Primary product sensor validity. |
| `shelly_sensor_error` | gauge | none | Product-specific sensor error code. |
| `shelly_connect_retries` | gauge | none | Wi-Fi retries during the current wake cycle. |
| `shelly_motion` | gauge | none | Motion detection state. |
| `shelly_motion_active` | gauge | none | Motion detection enabled/active state. |
| `shelly_motion_timestamp_seconds` | gauge | none | Motion reading Unix timestamp. |
| `shelly_vibration` | gauge | none | Motion sensor vibration/tamper state. |
| `shelly_smoke` | gauge | none | Smoke detection state. |
| `shelly_flood` | gauge | none | Flood detection state. |
| `shelly_rain_sensor_mode` | gauge | none | Flood sensor rain mode. |
| `shelly_door_window_info` | gauge | `state` | Door/window state, normally `open` or `close`. Value is always `1`. |
| `shelly_tilt_degrees` | gauge | none | Door/window tilt. |
| `shelly_vibration_value` | gauge | none | Raw door/window vibration state; `-1` can mean disabled. |
| `shelly_vibration_time_seconds` | gauge | none | Door/window vibration validity duration. |
| `shelly_adc_voltage_volts` | gauge | `index` | Shelly Uni ADC voltage. |
| `shelly_external_temperature_celsius` | gauge | `index`, `hardware_id` | External addon temperature. |
| `shelly_external_humidity_percent` | gauge | `index`, `hardware_id` | External addon humidity. |
## Shelly TRV
| Metric | Type | Additional labels | Description |
| --- | --- | --- | --- |
| `shelly_thermostat_valve_position_percent` | gauge | `index` | Valve position. A negative value can mean uncalibrated. |
| `shelly_thermostat_target_enabled` | gauge | `index` | Automatic target control state. |
| `shelly_thermostat_target_temperature_celsius` | gauge | `index` | Target temperature when reported in °C. |
| `shelly_thermostat_temperature_celsius` | gauge | `index` | Measured thermostat temperature. |
| `shelly_thermostat_temperature_valid` | gauge | `index` | Thermostat temperature validity. |
| `shelly_thermostat_schedule_enabled` | gauge | `index` | Schedule state. |
| `shelly_thermostat_schedule_profile` | gauge | `index` | Selected schedule profile. |
| `shelly_thermostat_boost_minutes` | gauge | `index` | Boost duration in minutes. |
| `shelly_thermostat_window_open` | gauge | `index` | Open-window state. |
| `shelly_thermostat_calibrated` | gauge | none | Valve calibration state. |
## Shelly Gas
| Metric | Type | Additional labels | Description |
| --- | --- | --- | --- |
| `shelly_gas_concentration_ppm` | gauge | none | Combustible gas concentration in ppm. |
| `shelly_gas_concentration_valid` | gauge | none | Concentration reading validity. |
| `shelly_gas_sensor_info` | gauge | `sensor_state`, `self_test_state`, `alarm_state` | Sensor operating and alarm state. Value is always `1`. |
| `shelly_gas_valve_info` | gauge | `index`, `state` | Valve addon state. Value is always `1`. |
+209
View File
@@ -0,0 +1,209 @@
# Operations and troubleshooting
## HTTP endpoints
| Path | Purpose | Device access |
| --- | --- | --- |
| `/metrics` | Prometheus/OpenMetrics output. Each request triggers collection from all configured devices. | yes |
| `/-/healthy` | Exporter process health; returns HTTP 200 and `OK`. | no |
The health endpoint proves that the HTTP process can respond. It deliberately does not contact Shelly devices. Use `shelly_up` to monitor individual device availability.
## Native deployment
```sh
go build -trimpath -o shelly-exporter .
./shelly-exporter -config config.yaml
```
The process handles `SIGINT` and `SIGTERM`, stops accepting requests, and gives the HTTP server up to 10 seconds to shut down.
Example systemd unit:
```ini
[Unit]
Description=Shelly Prometheus Exporter
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=shelly-exporter
Group=shelly-exporter
ExecStart=/usr/local/bin/shelly-exporter -config /etc/shelly-exporter/config.yaml
Restart=on-failure
RestartSec=5s
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadOnlyPaths=/etc/shelly-exporter/config.yaml
[Install]
WantedBy=multi-user.target
```
Ensure the service account can read the configuration and reach every device over the network.
## Docker deployment
Build the image:
```sh
docker build --rm -t shelly-exporter:latest .
```
Run it with a read-only configuration mount:
```sh
docker run --rm -d \
--name shelly-exporter \
-p 9090:9090 \
-v "$PWD/config.yaml:/opt/shelly-exporter/config.yaml:ro" \
shelly-exporter:latest
```
The image runs as an unprivileged user and includes a health check against `/-/healthy`. The configuration bundled in the image is only an example; mount the actual configuration for production.
## Prometheus setup
```yaml
scrape_configs:
- job_name: shelly
scrape_interval: 30s
scrape_timeout: 15s
static_configs:
- targets:
- shelly-exporter:9090
```
Keep the Prometheus `scrape_timeout` greater than `shelly.timeoutSeconds` plus a small allowance for metric encoding and network latency. Devices are queried concurrently, so the worst normal collection duration is close to the slowest device request rather than the sum of all requests.
The exporter contacts every device for every Prometheus scrape. Very short scrape intervals add unnecessary work to low-power hardware. A 1560 second interval is a reasonable starting point for powered devices.
### Battery-powered sensors
Shelly Gen1 H&T, Flood, Door/Window, Button1, and similar battery devices sleep to conserve power. They may be unavailable during most scheduled scrapes, causing expected `shelly_up == 0` results. Consider MQTT or event callbacks when continuous collection from these products is required.
## Useful PromQL
Current power per standard meter:
```promql
shelly_meter_power_watts
```
Consumed energy over 24 hours in kWh:
```promql
increase(shelly_meter_energy_watt_hours_total[24h]) / 1000
```
EM/3EM imported and exported energy over 24 hours in kWh:
```promql
increase(shelly_emeter_energy_watt_hours_total[24h]) / 1000
```
```promql
increase(shelly_emeter_returned_energy_watt_hours_total[24h]) / 1000
```
Devices currently unavailable:
```promql
shelly_up == 0
```
Free RAM percentage where both fields are reported:
```promql
100 * shelly_ram_free_bytes / shelly_ram_size_bytes
```
## Example alert rules
Adjust durations and exclude sleeping products according to the installation.
```yaml
groups:
- name: shelly
rules:
- alert: ShellyDeviceDown
expr: shelly_up == 0
for: 5m
labels:
severity: warning
annotations:
summary: "Shelly {{ $labels.device }} is unreachable"
- alert: ShellyDeviceOvertemperature
expr: shelly_overtemperature == 1
for: 1m
labels:
severity: critical
annotations:
summary: "Shelly {{ $labels.device }} reports overtemperature"
- alert: ShellyWeakWiFi
expr: shelly_wifi_rssi_dbm < -80
for: 10m
labels:
severity: warning
annotations:
summary: "Shelly {{ $labels.device }} has weak Wi-Fi signal"
```
## Security
- Store `config.yaml` outside source control and restrict it to the exporter account, for example with mode `0600` on Linux.
- Prefer a trusted management network or HTTPS because Basic Authentication does not encrypt credentials over plain HTTP.
- The exporter endpoints themselves do not require authentication. Restrict port 9090 with firewall, reverse proxy, or network policy when necessary.
- Device passwords are not metric labels, but request failures can include the target URL in logs. Do not put credentials in URL user-info; use `username` and `password` fields.
- HTTPS certificate verification is enabled and cannot be bypassed by configuration.
## Troubleshooting
### Exporter does not start
Read the fatal error first. Configuration loading is strict, and common causes are:
- an unknown or misspelled YAML key;
- a hostname in `host` instead of a literal IP address;
- an empty device list;
- duplicate device names;
- an unsupported product identifier;
- a URL missing `http://` or `https://`.
Increase `logLevel` to `5` or `6` only after configuration validation succeeds.
### `shelly_up` is zero
Test the same request from the exporter host or container network:
```sh
curl -v -u admin:password http://192.168.0.30/status
```
Check routing, firewall rules, device power, credentials, and the configured timeout. HTTP errors and JSON decoding failures increment `shelly_scrape_errors_total`.
### HTTP 401 Unauthorized
Confirm that the configured username and password match the device. The exporter uses HTTP Basic Authentication. A device configured for a different authentication scheme will not work with the current client.
### TLS certificate error
The device certificate must be trusted by the exporter operating system or container. Use a certificate issued by a trusted internal CA and add that CA to the runtime trust store.
### Device is up but an expected metric is absent
Metrics are intentionally conditional. Capture the raw `/status` JSON and verify that the corresponding field is present. Firmware versions and device modes can change response blocks—for example, Shelly 2.5 reports either relays or rollers depending on its operating mode.
For an unlisted Gen1 device, try `product: gen1`. If the field exists but is not modeled, follow the [extension guide](architecture.md#extending-the-gen1-schema).
### Scrapes time out
- Increase `shelly.timeoutSeconds` and Prometheus `scrape_timeout` together.
- Verify Wi-Fi RSSI and packet loss.
- Avoid overly short scrape intervals.
- Check whether a configured battery device is normally asleep.
+20
View File
@@ -0,0 +1,20 @@
module lostak.dev/shelly-exporter
go 1.22
require (
github.com/prometheus/client_golang v1.19.0
github.com/prometheus/client_model v0.5.0
github.com/sirupsen/logrus v1.9.3
gopkg.in/yaml.v2 v2.4.0
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/prometheus/common v0.48.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
golang.org/x/sys v0.16.0 // indirect
google.golang.org/protobuf v1.32.0 // indirect
)
+43
View File
@@ -0,0 +1,43 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+30
View File
@@ -0,0 +1,30 @@
package main
import (
"context"
"flag"
"os"
"os/signal"
"syscall"
log "github.com/sirupsen/logrus"
"lostak.dev/shelly-exporter/application"
)
func main() {
var configPath string
flag.StringVar(&configPath, "config", "config.yaml", "Path to the YAML configuration file.")
flag.Parse()
app, err := application.New(configPath)
if err != nil {
log.Fatalf("Cannot initialize Shelly exporter: %v", err)
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := app.Run(ctx); err != nil {
log.Fatalf("Shelly exporter stopped unexpectedly: %v", err)
}
}
+83
View File
@@ -0,0 +1,83 @@
// Package shelly provides HTTP communication shared by Shelly product implementations.
package shelly
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const maxErrorBodySize = 4 * 1024
// StatusClient is consumed by product collectors and can be replaced in tests.
type StatusClient interface {
GetStatus(context.Context, any) error
}
// Client fetches status data from one Shelly device.
type Client struct {
baseURL *url.URL
username string
password string
httpClient *http.Client
}
// NewClient creates a client that uses HTTP Basic Authentication when credentials are set.
func NewClient(rawURL, username, password string, timeout time.Duration) (*Client, error) {
baseURL, err := url.Parse(rawURL)
if err != nil {
return nil, fmt.Errorf("parse Shelly URL: %w", err)
}
return &Client{
baseURL: baseURL,
username: username,
password: password,
httpClient: &http.Client{
Timeout: timeout,
},
}, nil
}
// GetStatus decodes the /status response into destination.
func (c *Client) GetStatus(ctx context.Context, destination any) error {
statusURL := *c.baseURL
statusURL.Path = strings.TrimRight(statusURL.Path, "/") + "/status"
statusURL.RawQuery = ""
statusURL.Fragment = ""
request, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL.String(), nil)
if err != nil {
return fmt.Errorf("create status request: %w", err)
}
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "shelly-exporter")
if c.username != "" || c.password != "" {
request.SetBasicAuth(c.username, c.password)
}
response, err := c.httpClient.Do(request)
if err != nil {
return fmt.Errorf("request %s: %w", statusURL.String(), err)
}
defer response.Body.Close()
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
body, _ := io.ReadAll(io.LimitReader(response.Body, maxErrorBodySize))
message := strings.TrimSpace(string(body))
if message == "" {
return fmt.Errorf("request %s returned %s", statusURL.String(), response.Status)
}
return fmt.Errorf("request %s returned %s: %s", statusURL.String(), response.Status, message)
}
if err := json.NewDecoder(response.Body).Decode(destination); err != nil {
return fmt.Errorf("decode response from %s: %w", statusURL.String(), err)
}
return nil
}
+55
View File
@@ -0,0 +1,55 @@
package shelly
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestClientGetStatusUsesStatusEndpointAndBasicAuth(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/device/status" {
t.Errorf("request path = %q, want /device/status", request.URL.Path)
}
username, password, ok := request.BasicAuth()
if !ok || username != "admin" || password != "secret" {
t.Errorf("BasicAuth() = (%q, %q, %t), want admin, secret, true", username, password, ok)
}
response.Header().Set("Content-Type", "application/json")
_, _ = response.Write([]byte(`{"serial":42}`))
}))
defer server.Close()
client, err := NewClient(server.URL+"/device/", "admin", "secret", time.Second)
if err != nil {
t.Fatal(err)
}
var status struct {
Serial int `json:"serial"`
}
if err := client.GetStatus(context.Background(), &status); err != nil {
t.Fatalf("GetStatus() returned an unexpected error: %v", err)
}
if status.Serial != 42 {
t.Fatalf("Serial = %d, want 42", status.Serial)
}
}
func TestClientGetStatusReportsHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) {
http.Error(response, "not authorized", http.StatusUnauthorized)
}))
defer server.Close()
client, err := NewClient(server.URL, "admin", "wrong", time.Second)
if err != nil {
t.Fatal(err)
}
err = client.GetStatus(context.Background(), &struct{}{})
if err == nil || !strings.Contains(err.Error(), "401 Unauthorized") {
t.Fatalf("GetStatus() error = %v, want a 401 error", err)
}
}