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:], 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) r.Run(fmt.Sprintf(":%d", exporterPort)) } func (s *ShellyStatus) Fetch() { 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); 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 }