From 324e650b6a2cd6415ec68efc339b842701570d5d Mon Sep 17 00:00:00 2001 From: Jeff Evans Date: Sun, 20 Oct 2024 04:55:53 -0700 Subject: [PATCH] Add support for device discovery and additional devices (#34) * add logging for unrecognized device type * Implement device discovery for prometheus http service discovery * Log temp/humidity for remaining sensors that provide them * Ignore vscode settings * Use map instead of switch to filter supported device types * sort supported device types by name * update readme for new supported device types and service discovery feature * Separate config examples for static and dynamic * end .gitignore with newline * user simpler syntax for creating supportedDeviceTypes * fix dockerfile case warning => WARN: FromAsCasing: 'as' and 'FROM' keywords' casing do not match (line 1) --- .gitignore | 2 ++ Dockerfile | 2 +- README.md | 48 ++++++++++++++++++++++++++++++++++++-------- main.go | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 101 insertions(+), 10 deletions(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bbb286b --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# ignore vscode settings +.vscode/ diff --git a/Dockerfile b/Dockerfile index ed34455..c480652 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.22 as build +FROM golang:1.22 AS build COPY . . RUN GOPATH="" CGO_ENABLED=0 go build -o /switchbot_exporter diff --git a/README.md b/README.md index ad2b391..006a885 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,28 @@ # switchbot-exporter +Exports [switchbot](https://us.switch-bot.com) device metrics for [prometheus](https://prometheus.io). + ## Supported Devices / Metrics +Currently supports humidity and temperature for: +* Hub 2 +* Humidifier * Meter - * humidity - * temperature * Meter Plus - * humidity - * temperature +* Indoor/Outdoor Thermo-Hygrometer + +Supports weight and voltage for: * Plug Mini (JP) - * weight - * voltage ## Prometheus Configuration -The switchbot exporter needs to be passed the target ID as a parameter, this can be done with relabelling (like [blackbox exporter](https://github.com/prometheus/blackbox_exporter)) +### Static Configuration + +The switchbot exporter needs to be passed the target ID as a parameter, this can be done with relabelling (like [blackbox exporter](https://github.com/prometheus/blackbox_exporter)). -Example Config: +Change the host:port in the relabel_configs `replacement` to the host:port where the exporter is listening. + +#### Example Config (Static Configs): ``` yaml scrape_configs: @@ -34,7 +40,33 @@ scrape_configs: - target_label: __address__ replacement: 127.0.0.1:8080 # The switchbot exporter's real ip/port ``` +### Dynamic Configuration using Service Discovery + +The switchbot exporter also implements http service discovery to create a prometheus target for each supported device in your account. When using service discover, the `static_configs` is not needed. Relabeling is used (see [blackbox exporter](https://github.com/prometheus/blackbox_exporter)) to convert the device's id into a url with the id as the url's target query parameter. + +Change the host:port in the http_sd_configs `url` and in the relabel_configs `replacement` to the host:port where the exporter is listening. + +#### Example Config (Dynamic Configs): + +``` yaml +scrape_configs: + - job_name: 'switchbot' + scrape_interval: 5m # not to reach API rate limit + metrics_path: /metrics + http_sd_configs: + - url: http://127.0.0.1:8080/discover + refresh_interval: 1d # no need to check for new devices very often + relabel_configs: + - source_labels: [__address__] + target_label: __param_target + - source_labels: [__param_target] + target_label: instance + - target_label: __address__ + replacement: 127.0.0.1:8080 # The switchbot exporter's real ip/port +``` ## Limitation +Only a subset of switchbot devices are currently supported. + [switchbot API's request limit](https://github.com/OpenWonderLabs/SwitchBotAPI#request-limit) diff --git a/main.go b/main.go index 00cae4b..19200cd 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "errors" "flag" "fmt" @@ -28,6 +29,12 @@ var deviceLabels = prometheus.NewGaugeVec(prometheus.GaugeOpts{ Name: "device", }, []string{"device_id", "device_name"}) +// the type expected by the prometheus http service discovery +type StaticConfig struct { + Targets []string `json:"targets"` + Labels map[string]string `json:"labels"` +} + func main() { flag.Parse() if err := run(); err != nil { @@ -86,6 +93,51 @@ func run() error { } }() + http.HandleFunc("/discover", func(w http.ResponseWriter, r *http.Request) { + log.Printf("discovering devices...") + devices, _, err := sc.Device().List(r.Context()) + if err != nil { + http.Error(w, fmt.Sprintf("failed to discover devices: %s", err), http.StatusInternalServerError) + return + } + log.Printf("discovered device count: %d", len(devices)) + + supportedDeviceTypes := map[switchbot.PhysicalDeviceType]struct{}{ + switchbot.Hub2: {}, + switchbot.Humidifier: {}, + switchbot.Meter: {}, + switchbot.MeterPlus: {}, + switchbot.PlugMiniJP: {}, + switchbot.WoIOSensor: {}, + } + + data := make([]StaticConfig, len(devices)) + + for i, device := range devices { + _, deviceTypeIsSupported := supportedDeviceTypes[device.Type] + if !deviceTypeIsSupported { + log.Printf("ignoring device %s with unsupported type: %s", device.ID, device.Type) + continue + } + + log.Printf("discovered device %s of type %s", device.ID, device.Type) + staticConfig := StaticConfig{} + staticConfig.Targets = make([]string, 1) + staticConfig.Labels = make(map[string]string) + + staticConfig.Targets[0] = device.ID + staticConfig.Labels["device_id"] = device.ID + staticConfig.Labels["device_name"] = device.Name + staticConfig.Labels["device_type"] = string(device.Type) + + data[i] = staticConfig + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(data) + }) + http.HandleFunc("/-/reload", func(w http.ResponseWriter, r *http.Request) { if expectMethod := http.MethodPost; r.Method != expectMethod { w.WriteHeader(http.StatusMethodNotAllowed) @@ -109,14 +161,16 @@ func run() error { return } + log.Printf("getting device status: %s", target) status, err := sc.Device().Status(r.Context(), target) if err != nil { log.Printf("getting device status: %v", err) return } + log.Printf("got device status: %s", target) switch status.Type { - case switchbot.Meter, switchbot.MeterPlus: + case switchbot.Meter, switchbot.MeterPlus, switchbot.Hub2, switchbot.WoIOSensor, switchbot.Humidifier: meterHumidity := prometheus.NewGaugeVec(prometheus.GaugeOpts{ Namespace: "switchbot", Subsystem: "meter", @@ -158,6 +212,8 @@ func run() error { plugWeight.WithLabelValues(status.ID).Set(status.Weight) plugVoltage.WithLabelValues(status.ID).Set(status.Voltage) plugElectricCurrent.WithLabelValues(status.ID).Set(status.ElectricCurrent) + default: + log.Printf("unrecognized device type: %s", status.Type) } promhttp.HandlerFor(registry, promhttp.HandlerOpts{}).ServeHTTP(w, r) @@ -191,6 +247,7 @@ func reloadDevices(sc *switchbot.Client) error { if err != nil { return fmt.Errorf("getting device list: %w", err) } + log.Print("got device list") for _, device := range devices { deviceLabels.WithLabelValues(device.ID, device.Name).Set(0)