2023-08-10 12:17:26 +02:00
|
|
|
package main
|
|
|
|
|
|
|
|
import (
|
|
|
|
"bufio"
|
|
|
|
"encoding/json"
|
|
|
|
"flag"
|
|
|
|
"fmt"
|
|
|
|
"io"
|
|
|
|
"log"
|
|
|
|
"net/http"
|
|
|
|
"os"
|
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/peterbourgon/ff/v3"
|
|
|
|
"github.com/robfig/cron/v3"
|
|
|
|
)
|
|
|
|
|
|
|
|
type ShellyStatus struct {
|
|
|
|
Wifi ShellyWifiStatus `json:"wifi_sta"`
|
|
|
|
Cloud ShellyCloudStatus `json:"cloud"`
|
|
|
|
Mqtt ShellyMqttStatus `json:"mqtt"`
|
|
|
|
Time string `json:"time"`
|
|
|
|
Serial int `json:"serial"`
|
|
|
|
HasUpdate bool `json:"has_update"`
|
|
|
|
MAC string `json:"mac"`
|
|
|
|
Relays []ShellyRelaysStatus `json:"relays"`
|
|
|
|
Meters []ShellyMetersStatus `json:"meters"`
|
|
|
|
Temperature float32 `json:"temperature"`
|
|
|
|
OverTemperature bool `json:"overtemperature"`
|
|
|
|
Update ShellyUpdateStatus `json:"update"`
|
|
|
|
RamTotal int `json:"ram_total"`
|
|
|
|
RamFree int `json:"ram_free"`
|
|
|
|
FsSize int `json:"fs_size"`
|
|
|
|
FsFree int `json:"fs_free"`
|
|
|
|
Uptime int `json:"uptime"`
|
|
|
|
}
|
|
|
|
|
|
|
|
type ShellyWifiStatus struct {
|
|
|
|
Connected bool `json:"connected"`
|
|
|
|
SSID string `json:"ssid"`
|
|
|
|
IP string `json:"ip"`
|
|
|
|
RSSI int `json:"rssi"`
|
|
|
|
}
|
|
|
|
|
|
|
|
type ShellyCloudStatus struct {
|
|
|
|
Enabled bool `json:"enabled"`
|
|
|
|
Connected bool `json:"connected"`
|
|
|
|
}
|
|
|
|
|
|
|
|
type ShellyMqttStatus struct {
|
|
|
|
Connected bool `json:"connected"`
|
|
|
|
}
|
|
|
|
|
|
|
|
type ShellyRelaysStatus struct {
|
|
|
|
IsOn bool `json:"ison"`
|
|
|
|
HasTimer bool `json:"has_timer"`
|
|
|
|
Overpower bool `json:"overpower"`
|
|
|
|
}
|
|
|
|
|
|
|
|
type ShellyMetersStatus struct {
|
|
|
|
Power float32 `json:"power"`
|
|
|
|
IsValid bool `json:"is_valid"`
|
|
|
|
Timestamp int `json:"timestamp"`
|
|
|
|
Counters []float32 `json:"counters"`
|
|
|
|
Total int `json:"total"`
|
|
|
|
}
|
|
|
|
|
|
|
|
type ShellyUpdateStatus struct {
|
|
|
|
Status string `json:"status"`
|
|
|
|
HasUpdate bool `json:"has_update"`
|
|
|
|
NewVersion string `json:"new_version"`
|
|
|
|
OldVersion string `json:"old_version"`
|
|
|
|
}
|
|
|
|
|
|
|
|
var (
|
|
|
|
flagset = flag.NewFlagSet("shelly-proxy", flag.ContinueOnError)
|
|
|
|
|
|
|
|
// config flags
|
|
|
|
shellyHost = flagset.String("shellyplug-host", "192.168.30.1", "Shellyplug address (also via SHELLYPLUG_HOST)")
|
|
|
|
shellyPort = flagset.Int("shellyplug-port", 80, "Shellyplug port (also via SHELLYPLUG_HOST)")
|
|
|
|
//shellyAuthUser = flagset.String("shellyplug-auth-username", "", "Shellyplug authentication username (also via SHELLYPLUG_AUTH_USERNAME)")
|
|
|
|
//shellyAuthPass = flagset.String("shellyplug-auth-password", "", "Shellyplug authentication username (also via SHELLYPLUG_AUTH_PASSWORD)")
|
|
|
|
exporterPort = flagset.Int("exporter-port", 9000, "Exporter port (also via EXPORTER_PORT)")
|
|
|
|
|
|
|
|
// additional flags
|
|
|
|
_ = flagset.String("config", "", "Path to config file (ini format)")
|
|
|
|
//versionInfo = flagset.Bool("version", false, "Show version information")
|
|
|
|
|
|
|
|
// internal
|
|
|
|
ss *ShellyStatus
|
|
|
|
)
|
|
|
|
|
|
|
|
func main() {
|
|
|
|
|
|
|
|
// use .env file if it exists
|
|
|
|
if _, err := os.Stat(".env"); err == nil {
|
|
|
|
if err := ff.Parse(flagset, os.Args[1:],
|
2023-08-10 12:36:00 +02:00
|
|
|
ff.WithEnvVarPrefix(""),
|
2023-08-10 12:17:26 +02:00
|
|
|
ff.WithConfigFile(".env"),
|
|
|
|
ff.WithConfigFileParser(ff.EnvParser),
|
|
|
|
); err != nil {
|
|
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
|
|
os.Exit(1)
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
// use env variables and shelly-proxy.ini file
|
|
|
|
if err := ff.Parse(flagset, os.Args[1:],
|
2023-08-10 12:36:00 +02:00
|
|
|
ff.WithEnvVarPrefix(""),
|
2023-08-10 12:17:26 +02:00
|
|
|
ff.WithConfigFileFlag("config"),
|
|
|
|
ff.WithConfigFileParser(IniParser),
|
|
|
|
); err != nil {
|
|
|
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
|
|
os.Exit(1)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
ss = &ShellyStatus{}
|
|
|
|
ss.Fetch()
|
|
|
|
|
|
|
|
c := cron.New(cron.WithLocation(time.UTC))
|
|
|
|
if _, err := c.AddFunc("@every 1m", func() { ss.Fetch() }); err != nil {
|
|
|
|
log.Fatalf("cron.AddFunc (%s)", err)
|
|
|
|
}
|
|
|
|
c.Start()
|
|
|
|
|
|
|
|
gin.SetMode(gin.ReleaseMode)
|
|
|
|
r := gin.Default()
|
|
|
|
r.GET("/metrics", GetMetrics)
|
|
|
|
|
2023-08-10 12:45:44 +02:00
|
|
|
if err := r.Run(fmt.Sprintf(":%d", *exporterPort)); err != nil {
|
2023-08-10 12:44:46 +02:00
|
|
|
log.Fatalf("router.Run (%s)", err)
|
|
|
|
}
|
2023-08-10 12:17:26 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
func (s *ShellyStatus) Fetch() {
|
2023-08-10 12:24:52 +02:00
|
|
|
resp, err := http.Get(fmt.Sprintf("http://%s:%d/status", *shellyHost, *shellyPort))
|
2023-08-10 12:17:26 +02:00
|
|
|
if err != nil {
|
|
|
|
log.Fatalf("ShellyStatus.Fetch : http.Get (%s)", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
|
|
if err != nil {
|
|
|
|
log.Fatalf("ShellyStatus.Fetch : io.ReadAll (%s)", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
if err = json.Unmarshal(body, s); err != nil {
|
|
|
|
log.Fatalf("ShellyStatus.Fetch : json.Unmarshal (%s)", err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func GetMetrics(c *gin.Context) {
|
|
|
|
c.String(http.StatusOK, `# HELP shellyplug_power Current power drawn in watts
|
|
|
|
# TYPE shellyplug_power gauge
|
|
|
|
shellyplug_power %.2f
|
|
|
|
# HELP shellyplug_overpower Overpower drawn in watts/minute
|
|
|
|
# TYPE shellyplug_overpower gauge
|
|
|
|
shellyplug_overpower %.2f
|
|
|
|
# HELP shellyplug_total_power Total power consumed in watts/minute
|
|
|
|
# TYPE shellyplug_total_power counter
|
|
|
|
shellyplug_total_power %d
|
|
|
|
# HELP shellyplug_temperature Plug temperature in celsius
|
|
|
|
# TYPE shellyplug_temperature gauge
|
|
|
|
shellyplug_temperature %.2f
|
|
|
|
# HELP shellyplug_uptime Plug uptime in seconds
|
|
|
|
# TYPE shellyplug_uptime gauge
|
|
|
|
shellyplug_uptime %d
|
|
|
|
`, ss.Meters[0].Counters[0], float32(0), ss.Meters[0].Total, ss.Temperature, ss.Uptime)
|
|
|
|
}
|
|
|
|
|
|
|
|
// IniParser is a parser for config files in classic key/value style format. Each
|
|
|
|
// line is tokenized as a single key/value pair. The first "=" delimited
|
|
|
|
// token in the line is interpreted as the flag name, and all remaining tokens
|
|
|
|
// are interpreted as the value. Any leading hyphens on the flag name are
|
|
|
|
// ignored.
|
|
|
|
func IniParser(r io.Reader, set func(name, value string) error) error {
|
|
|
|
s := bufio.NewScanner(r)
|
|
|
|
for s.Scan() {
|
|
|
|
line := strings.TrimSpace(s.Text())
|
|
|
|
if line == "" {
|
|
|
|
continue // skip empties
|
|
|
|
}
|
|
|
|
|
|
|
|
if line[0] == '#' || line[0] == ';' {
|
|
|
|
continue // skip comments
|
|
|
|
}
|
|
|
|
|
|
|
|
var (
|
|
|
|
name string
|
|
|
|
value string
|
|
|
|
index = strings.IndexRune(line, '=')
|
|
|
|
)
|
|
|
|
if index < 0 {
|
|
|
|
name, value = line, "true" // boolean option
|
|
|
|
} else {
|
|
|
|
name, value = strings.TrimSpace(line[:index]), strings.Trim(strings.TrimSpace(line[index+1:]), "\"")
|
|
|
|
}
|
|
|
|
|
|
|
|
if i := strings.Index(value, " #"); i >= 0 {
|
|
|
|
value = strings.TrimSpace(value[:i])
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := set(name, value); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|