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" ) var ( flagset = flag.NewFlagSet("shelly-proxy", flag.ContinueOnError) // config flags shellyHost = flagset.String("shellyplug-host", "192.168.33.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", 5000, "Exporter port (also via EXPORTER_PORT)") exporterName = flagset.String("exporter-name", "shelly_plug", "Exporter name (also via EXPORTER_NAME)") // additional flags _ = flagset.String("config", "", "Path to config file (ini format)") //versionInfo = flagset.Bool("version", false, "Show version information") // internal ss *ShellyStatus ) type ShellyStatus struct { Status1 *ShellyStatus1 Status2 *ShellyStatus2 Version int } type ShellyVersion struct { Gen int `json:"gen"` Type string `json:"type"` } func main() { // use .env file if it exists if _, err := os.Stat(".env"); err == nil { if err := ff.Parse(flagset, os.Args[1:], ff.WithEnvVarPrefix(""), 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:], ff.WithEnvVarPrefix(""), 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) if err := r.Run(fmt.Sprintf(":%d", *exporterPort)); err != nil { log.Fatalf("router.Run (%s)", err) } } func (s *ShellyStatus) GetVersion() int { if s.Version != 0 { return s.Version } resp, err := http.Get(fmt.Sprintf("http://%s:%d/shelly", *shellyHost, *shellyPort)) if err != nil { log.Fatalf("ShellyStatus.GetVersion : http.Get (%s)", err) } body, err := io.ReadAll(resp.Body) if err != nil { log.Fatalf("ShellyStatus.GetVersion : io.ReadAll (%s)", err) } v := ShellyVersion{} if err = json.Unmarshal(body, &v); err != nil { log.Fatalf("ShellyStatus.GetVersion : json.Unmarshal (%s)", err) } if v.Type != "" { s.Version = 1 s.Status1 = &ShellyStatus1{} } if v.Gen != 0 { s.Version = v.Gen s.Status2 = &ShellyStatus2{} } return s.Version } func (s *ShellyStatus) Fetch() { switch s.GetVersion() { case 1: resp, err := http.Get(fmt.Sprintf("http://%s:%d/status", *shellyHost, *shellyPort)) 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.Status1); err != nil { log.Fatalf("ShellyStatus.Fetch : json.Unmarshal (%s)", err) } return case 2: resp, err := http.Get(fmt.Sprintf("http://%s:%d/rpc/Shelly.GetStatus", *shellyHost, *shellyPort)) 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.Status2); err != nil { log.Fatalf("ShellyStatus.Fetch : json.Unmarshal (%s)", err) } return default: return } } func GetMetrics(c *gin.Context) { switch ss.Version { case 1: c.String(http.StatusOK, `shellyplug_name %s # 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 `, *exporterName, ss.Status1.Meters[0].Counters[0], float32(0), 0, ss.Status1.Temperature, ss.Status1.Uptime) case 2: c.String(http.StatusOK, `shellyplug_name %s # 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 `, *exporterName, ss.Status2.Switch.Power, float32(0), 0, ss.Status2.Switch.Temperature.Celsius, ss.Status2.Sys.Uptime) default: c.String(http.StatusOK, `shellyplug_name %s # 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 `, *exporterName, float32(0), float32(0), 0, float32(0), 0) } } // 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 }