Repository: evcc-io/evcc
Pull request #6205 (2023-02-11 17:56:07)

Add products api
Refs https://github.com/evcc-io/evcc/issues/6029

- [x] add `/config/products/<class>` api
- [x] make templates api language-aware


Patch:
diff --git a/meter/shelly/connection.go b/meter/shelly/connection.go
index 191ee7c0..b618da83 100644
--- a/meter/shelly/connection.go
+++ b/meter/shelly/connection.go
@@ -15,9 +15,10 @@ import (
 // Connection is the Shelly connection
 type Connection struct {
 	*request.Helper
-	uri     string
-	channel int
-	gen     int // Shelly api generation
+	uri        string
+	channel    int
+	gen        int    // Shelly api generation
+	devicetype string // Shelly device type
 }
 
 // NewConnection creates a new Shelly device connection.
@@ -40,9 +41,10 @@ func NewConnection(uri, user, password string, channel int) (*Connection, error)
 	}
 
 	conn := &Connection{
-		Helper:  client,
-		channel: channel,
-		gen:     resp.Gen,
+		Helper:     client,
+		channel:    channel,
+		gen:        resp.Gen,
+		devicetype: resp.Type,
 	}
 
 	conn.Client.Transport = request.NewTripper(log, transport.Insecure())
@@ -184,13 +186,15 @@ func (d *Connection) TotalEnergy() (float64, error) {
 
 		switch {
 		case d.channel < len(res.Meters):
-			energy = res.Meters[d.channel].Total / 60
+			energy = res.Meters[d.channel].Total
 		case d.channel < len(res.EMeters):
-			energy = res.EMeters[d.channel].Total / 60
+			energy = res.EMeters[d.channel].Total
 		default:
 			return 0, errors.New("invalid channel, missing power meter")
 		}
 
+		energy = GetGen1Energy(d.devicetype, energy)
+
 	default:
 		var res Gen2StatusResponse
 		if err := d.execGen2Cmd("Shelly.GetStatus", false, &res); err != nil {
@@ -209,3 +213,12 @@ func (d *Connection) TotalEnergy() (float64, error) {
 
 	return energy / 1000, nil
 }
+
+// GetGen1Energy in kWh
+func GetGen1Energy(devicetype string, energy float64) float64 {
+	// Gen 1 Shelly EM devices are providing Watt hours, Shelly EM devices are providing Watt minutes
+	if !strings.Contains(devicetype, "EM") {
+		energy = energy / 60
+	}
+	return energy
+}
diff --git a/server/http.go b/server/http.go
index fc631d8b..ee7100a5 100644
--- a/server/http.go
+++ b/server/http.go
@@ -98,6 +98,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, cache *util.Cache) {
 		"health":        {[]string{"GET"}, "/health", healthHandler(site)},
 		"state":         {[]string{"GET"}, "/state", stateHandler(cache)},
 		"config":        {[]string{"GET"}, "/config/templates/{class:[a-z]+}", templatesHandler},
+		"products":      {[]string{"GET"}, "/config/products/{class:[a-z]+}", productsHandler},
 		"test":          {[]string{"POST"}, "/config/test/{class:[a-z]+}", testHandler},
 		"buffersoc":     {[]string{"POST", "OPTIONS"}, "/buffersoc/{value:[0-9.]+}", floatHandler(site.SetBufferSoc, site.GetBufferSoc)},
 		"prioritysoc":   {[]string{"POST", "OPTIONS"}, "/prioritysoc/{value:[0-9.]+}", floatHandler(site.SetPrioritySoc, site.GetPrioritySoc)},
diff --git a/server/http_config_handler.go b/server/http_config_handler.go
index 898fd961..2a3e68d6 100644
--- a/server/http_config_handler.go
+++ b/server/http_config_handler.go
@@ -2,7 +2,9 @@ package server
 
 import (
 	"encoding/json"
+	"errors"
 	"net/http"
+	"strings"
 
 	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/charger"
@@ -10,6 +12,7 @@ import (
 	"github.com/evcc-io/evcc/util/templates"
 	"github.com/evcc-io/evcc/vehicle"
 	"github.com/gorilla/mux"
+	"golang.org/x/exp/slices"
 )
 
 // templatesHandler returns the list of templates by class
@@ -23,6 +26,93 @@ func templatesHandler(w http.ResponseWriter, r *http.Request) {
 	}
 
 	res := templates.ByClass(class)
+
+	lang := r.URL.Query().Get("lang")
+	templates.EncoderLanguage(lang)
+
+	if name := r.URL.Query().Get("name"); name != "" {
+		for _, t := range res {
+			if t.TemplateDefinition.Template == name {
+				jsonResult(w, t)
+				return
+			}
+		}
+
+		jsonError(w, http.StatusBadRequest, errors.New("template not found"))
+		return
+	}
+
+	jsonResult(w, res)
+}
+
+type product struct {
+	Name     string `json:"name"`
+	Template string `json:"template"`
+}
+
+type products []product
+
+func (p products) MarshalJSON() (out []byte, err error) {
+	if p == nil {
+		return []byte(`null`), nil
+	}
+	if len(p) == 0 {
+		return []byte(`{}`), nil
+	}
+
+	out = append(out, '{')
+	for _, e := range p {
+		key, err := json.Marshal(e.Name)
+		if err != nil {
+			return nil, err
+		}
+		val, err := json.Marshal(e.Template)
+		if err != nil {
+			return nil, err
+		}
+		out = append(out, key...)
+		out = append(out, ':')
+		out = append(out, val...)
+		out = append(out, ',')
+	}
+
+	// replace last ',' with '}'
+	if len(out) > 1 {
+		out[len(out)-1] = '}'
+	} else {
+		out = append(out, '}')
+	}
+
+	return out, nil
+}
+
+// productsHandler returns the list of products by class
+func productsHandler(w http.ResponseWriter, r *http.Request) {
+	vars := mux.Vars(r)
+
+	class, err := templates.ClassString(vars["class"])
+	if err != nil {
+		jsonError(w, http.StatusBadRequest, err)
+		return
+	}
+
+	tmpl := templates.ByClass(class)
+	lang := r.URL.Query().Get("lang")
+
+	res := make(products, 0)
+	for _, t := range tmpl {
+		for _, p := range t.Products {
+			res = append(res, product{
+				Name:     p.Title(lang),
+				Template: t.TemplateDefinition.Template,
+			})
+		}
+	}
+
+	slices.SortFunc(res, func(a, b product) bool {
+		return strings.ToLower(a.Name) < strings.ToLower(b.Name)
+	})
+
 	jsonResult(w, res)
 }
 
diff --git a/util/templates/init.go b/util/templates/init.go
index 8a0fac2d..544a55b3 100644
--- a/util/templates/init.go
+++ b/util/templates/init.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"io/fs"
 	"path"
+	"sync"
 
 	"github.com/evcc-io/evcc/templates/definition"
 	"golang.org/x/exp/slices"
@@ -14,6 +15,9 @@ import (
 var (
 	templates      = make(map[Class][]Template)
 	configDefaults = ConfigDefaults{}
+
+	mu              sync.Mutex
+	encoderLanguage string
 )
 
 func init() {
@@ -98,6 +102,13 @@ func loadTemplates(class Class) {
 	}
 }
 
+// EncoderLanguage sets the template language for encoding json
+func EncoderLanguage(lang string) {
+	mu.Lock()
+	defer mu.Unlock()
+	encoderLanguage = lang
+}
+
 func ByClass(class Class) []Template {
 	return templates[class]
 }
diff --git a/util/templates/template_types.go b/util/templates/types.go
similarity index 97%
rename from util/templates/template_types.go
rename to util/templates/types.go
index 9df7b426..330e63c4 100644
--- a/util/templates/template_types.go
+++ b/util/templates/types.go
@@ -2,6 +2,7 @@ package templates
 
 import (
 	"bufio"
+	"encoding/json"
 	"fmt"
 	"strings"
 
@@ -145,6 +146,14 @@ func (t *TextLanguage) Update(new TextLanguage, always bool) {
 	}
 }
 
+// MarshalJSON implements the json.Marshaler interface
+func (t *TextLanguage) MarshalJSON() (out []byte, err error) {
+	mu.Lock()
+	s := t.String(encoderLanguage)
+	mu.Unlock()
+	return json.Marshal(s)
+}
+
 // Requirements
 type Requirements struct {
 	EVCC        []string     // EVCC requirements, e.g. sponsorship


Test patch:
diff --git a/meter/shelly/types_test.go b/meter/shelly/types_test.go
new file mode 100644
index 00000000..d2c23b83
--- /dev/null
+++ b/meter/shelly/types_test.go
@@ -0,0 +1,67 @@
+package shelly
+
+import (
+	"encoding/json"
+	"testing"
+)
+
+// Test Gen1StatusResponse response
+func TestUnmarshalGen1StatusResponse(t *testing.T) {
+	var res Gen1StatusResponse
+
+	// Shelly 1 PM channel 0 (1)
+	jsonstr := `{"wifi_sta":{"connected":true,"ssid":"XXXX-WLAN","ip":"192.168.178.XXX","rssi":-54},"cloud":{"enabled":false,"connected":false},"mqtt":{"connected":false},"time":"17:59","unixtime":1676134770,"serial":2437,"has_update":true,"mac":"84CCA8XXXXXXX","cfg_changed_cnt":1,"actions_stats":{"skipped":0},"relays":[{"ison":false,"has_timer":false,"timer_started":0,"timer_duration":0,"timer_remaining":0,"overpower":false,"source":"http"}],"meters":[{"power":4711.12,"overpower":0.00,"is_valid":true,"timestamp":1676138370,"counters":[0.000, 0.000, 0.000],"total":6472513}],"inputs":[{"input":0,"event":"","event_cnt":0}],"temperature":16.79,"overtemperature":false,"tmp":{"tC":16.79,"tF":62.22, "is_valid":true},"temperature_status":"Normal","ext_sensors":{},"ext_temperature":{},"ext_humidity":{},"update":{"status":"pending","has_update":true,"new_version":"20221108-153925/v1.12.1-1PM-fix-g2821131","old_version":"20220209-094317/v1.11.8-g8c7bb8d"},"ram_total":50456,"ram_free":37056,"fs_size":233681,"fs_free":149094,"uptime":17284290}`
+	if err := json.Unmarshal([]byte(jsonstr), &res); err != nil {
+		t.Error(err)
+	}
+	if GetGen1Energy("SHSW-PM", res.Meters[0].Total) != 107875.21666666666 {
+		t.Error("SHSW-PM res.Meters[0].Total) != 107875.21666666666")
+	}
+	if res.Meters[0].Power != 4711.12 {
+		t.Error("res.Meters[0].Power != 4711.12")
+	}
+
+	// Shelly 1 channel 0 (1)
+	res = Gen1StatusResponse{}
+	jsonstr = `{"wifi_sta":{"connected":true,"ssid":"XXXX-WLAN","ip":"192.168.178.XXX","rssi":-57},"cloud":{"enabled":false,"connected":false},"mqtt":{"connected":false},"time":"19:25","unixtime":1676139913,"serial":959,"has_update":true,"mac":"E8DB8XXXXXX","cfg_changed_cnt":1,"actions_stats":{"skipped":0},"relays":[{"ison":false,"has_timer":false,"timer_started":0,"timer_duration":0,"timer_remaining":0,"source":"timer"}],"meters":[{"power":81.5,"is_valid":true}],"inputs":[{"input":0,"event":"","event_cnt":0}],"ext_sensors":{},"ext_temperature":{},"ext_humidity":{},"update":{"status":"pending","has_update":true,"new_version":"20221027-091427/v1.12.1-ga9117d3","old_version":"20211109-124958/v1.11.7-g682a0db"},"ram_total":50880,"ram_free":38796,"fs_size":233681,"fs_free":151102,"uptime":20319391}`
+	if err := json.Unmarshal([]byte(jsonstr), &res); err != nil {
+		t.Error(err)
+	}
+	if GetGen1Energy("SHSW-1", res.Meters[0].Total) != 0 {
+		t.Error("SHSW-1 res.Meters[0].Total) != 0")
+	}
+	if res.Meters[0].Power != 81.5 {
+		t.Error("res.Meters[0].Power != -81.5")
+	}
+
+	// Shelly EM channel 0 (1)
+	res = Gen1StatusResponse{}
+	jsonstr = `{"wifi_sta":{"connected":true,"ssid":"XXXX","ip":"192.168.178.XXX","rssi":-55},"cloud":{"enabled":false,"connected":false},"mqtt":{"connected":false},"time":"11:16","unixtime":1676110566,"serial":21580,"has_update":false,"mac":"C45BXXXXX","cfg_changed_cnt":0,"actions_stats":{"skipped":0},"relays":[{"ison":false,"has_timer":false,"timer_started":0,"timer_duration":0,"timer_remaining":0,"overpower":false,"is_valid":true,"source":"input"}],"emeters":[{"power":-620.34,"reactive":714.48,"pf":-0.66,"voltage":235.68,"is_valid":true,"total":401472.9,"total_returned":653673.7},{"power":0.00,"reactive":0.00,"pf":0.00,"voltage":235.68,"is_valid":true,"total":173411.3,"total_returned":294.2}],"update":{"status":"idle","has_update":false,"new_version":"20221027-105518/v1.12.1-ga9117d3","old_version":"20221027-105518/v1.12.1-ga9117d3"},"ram_total":51072,"ram_free":35660,"fs_size":233681,"fs_free":156373,"uptime":2226140}`
+	if err := json.Unmarshal([]byte(jsonstr), &res); err != nil {
+		t.Error(err)
+	}
+	if GetGen1Energy("SHEM", res.EMeters[0].Total) != 401472.9 {
+		t.Error("SHEM res.EMeters[0].Total) != 401472.9")
+	}
+	if res.EMeters[0].Power != -620.34 {
+		t.Error("res.EMeters[0].Power != -620.34")
+	}
+
+}
+
+// Test Gen2StatusResponse response
+func TestUnmarshalGen2StatusResponse(t *testing.T) {
+	var res Gen2StatusResponse
+
+	// Shelly Pro 1PM channel 0 (1)
+	jsonstr := `{"ble":{},"cloud":{"connected":true},"eth":{"ip":null},"input:0":{"id":0,"state":false},"input:1":{"id":1,"state":false},"mqtt":{"connected":false},"switch:0":{"id":0, "source":"HTTP", "output":false, "apower":47.11, "voltage":232.0, "current":0.000, "pf":0.00, "aenergy":{"total":5.125,"by_minute":[0.000,0.000,0.000],"minute_ts":1675718520},"temperature":{"tC":25.3, "tF":77.5}},"sys":{"mac":"30C6F78BB4D8","restart_required":false,"time":"22:22","unixtime":1675718522,"uptime":45070,"ram_size":234204,"ram_free":137716,"fs_size":524288,"fs_free":172032,"cfg_rev":13,"kvs_rev":1,"schedule_rev":0,"webhook_rev":0,"available_updates":{"beta":{"version":"0.13.0-beta3"}}},"wifi":{"sta_ip":"192.168.178.64","status":"got ip","ssid":"***","rssi":-62},"ws":{"connected":false}}`
+	if err := json.Unmarshal([]byte(jsonstr), &res); err != nil {
+		t.Error(err)
+	}
+	if res.Switch0.Aenergy.Total != 5.125 {
+		t.Error("res.Switch0.Aenergy.Total != 5.125")
+	}
+	if res.Switch0.Apower != 47.11 {
+		t.Error("res.Switch0.Apower != 47.11")
+	}
+}