Skip to content

Commit

Permalink
Add support for device discovery and additional devices (#34)
Browse files Browse the repository at this point in the history
* 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)
  • Loading branch information
reznet authored Oct 20, 2024
1 parent 2faf20a commit 324e650
Show file tree
Hide file tree
Showing 4 changed files with 101 additions and 10 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# ignore vscode settings
.vscode/
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -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

Expand Down
48 changes: 40 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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)
59 changes: 58 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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",
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down

0 comments on commit 324e650

Please sign in to comment.