From a90c69e8b38016df25630246d1a969cb69b9dfa2 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 26 Sep 2021 20:23:33 +0200 Subject: [PATCH] Add Go-E v2 api support (#1609) --- charger/go-e.go | 253 +++++++++++++------------------------ charger/go-e/api.go | 160 +++++++++++++++++++++++ charger/go-e/api_test.go | 68 ++++++++++ charger/go-e/types.go | 86 +++++++++++++ charger/go-e/types2.go | 65 ++++++++++ charger/go-e_decorators.go | 69 ++++++++++ charger/go-e_test.go | 89 +++++++++++++ 7 files changed, 625 insertions(+), 165 deletions(-) create mode 100644 charger/go-e/api.go create mode 100644 charger/go-e/api_test.go create mode 100644 charger/go-e/types.go create mode 100644 charger/go-e/types2.go create mode 100644 charger/go-e_decorators.go create mode 100644 charger/go-e_test.go diff --git a/charger/go-e.go b/charger/go-e.go index 317fa5f5d..61888045b 100644 --- a/charger/go-e.go +++ b/charger/go-e.go @@ -3,90 +3,29 @@ package charger import ( "errors" "fmt" - "strings" "time" "github.com/evcc-io/evcc/api" + goe "github.com/evcc-io/evcc/charger/go-e" "github.com/evcc-io/evcc/util" - "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/util/sponsor" ) // https://go-e.co/app/api.pdf - -const goeCloud = "https://api.go-e.co" - -// goeCloudResponse is the cloud API response -type goeCloudResponse struct { - Success *bool `json:"success"` // only valid for cloud payload commands - Age int `json:"age"` - Error string `json:"error"` // only valid for cloud payload commands - Data goeStatusResponse `json:"data"` -} - -// goeStatusResponse is the API response if status not OK -type goeStatusResponse struct { - Fwv string `json:"fwv"` // firmware version - indicates local response - Car int `json:"car,string"` // car status - Alw int `json:"alw,string"` // allow charging - Amp int `json:"amp,string"` // current [A] - Err int `json:"err,string"` // error - Stp int `json:"stp,string"` // stop state - Tmp int `json:"tmp,string"` // temperature [°C] - Dws int `json:"dws,string"` // energy [Ws] - Nrg []int `json:"nrg"` // voltage, current, power - Uby int `json:"uby,string"` // unlocked_by - Rna string `json:"rna"` // RFID 1 - Rnm string `json:"rnm"` // RFID 2 - Rne string `json:"rne"` // RFID 3 - Rn4 string `json:"rn4"` // RFID 4 - Rn5 string `json:"rn5"` // RFID 5 - Rn6 string `json:"rn6"` // RFID 6 - Rn7 string `json:"rn7"` // RFID 7 - Rn8 string `json:"rn8"` // RFID 8 - Rn9 string `json:"rn9"` // RFID 9 - Rn1 string `json:"rn1"` // RFID 10 -} - -func (g *goeStatusResponse) RFIDName() string { - switch g.Uby { - case 1: - return g.Rna - case 2: - return g.Rnm - case 3: - return g.Rne - case 4: - return g.Rn4 - case 5: - return g.Rn5 - case 6: - return g.Rn6 - case 7: - return g.Rn7 - case 8: - return g.Rn8 - case 9: - return g.Rn9 - case 10: - return g.Rn1 - default: - return "" - } -} +// https://github.com/goecharger/go-eCharger-API-v1/ +// https://github.com/goecharger/go-eCharger-API-v2/ // GoE charger implementation type GoE struct { - *request.Helper - uri, token string - cache time.Duration - updated time.Time - status goeStatusResponse + api goe.API } func init() { registry.Add("go-e", NewGoEFromConfig) } +// go:generate go run ../cmd/tools/decorate.go -f decorateGoE -b *GoE -r api.Charger -t "api.MeterEnergy,TotalEnergy,func() (float64, error)" -t "api.ChargePhases,Phases1p3p,func(int) (error)" + // NewGoEFromConfig creates a go-e charger from generic config func NewGoEFromConfig(other map[string]interface{}) (api.Charger, error) { cc := struct { @@ -94,99 +33,55 @@ func NewGoEFromConfig(other map[string]interface{}) (api.Charger, error) { URI string Cache time.Duration }{} + if err := util.DecodeOther(other, &cc); err != nil { return nil, err } if cc.URI != "" && cc.Token != "" { - return nil, errors.New("go-e config: should only have one of uri/token") + return nil, errors.New("should only have one of uri/token") } if cc.URI == "" && cc.Token == "" { - return nil, errors.New("go-e config: must have one of uri/token") + return nil, errors.New("must have one of uri/token") } return NewGoE(cc.URI, cc.Token, cc.Cache) } // NewGoE creates GoE charger -func NewGoE(uri, token string, cache time.Duration) (*GoE, error) { - c := &GoE{ - Helper: request.NewHelper(util.NewLogger("go-e")), - uri: strings.TrimRight(uri, "/"), - token: strings.TrimSpace(token), +func NewGoE(uri, token string, cache time.Duration) (api.Charger, error) { + c := &GoE{} + + log := util.NewLogger("go-e") + + if token != "" { + c.api = goe.NewCloud(log, token, cache) + } else { + c.api = goe.NewLocal(log, uri) + } + + if c.api.IsV2() { + var phases func(int) error + if sponsor.IsAuthorized() { + phases = c.phases1p3p + } else { + log.WARN.Println("automatic 1p3p phase switching requires sponsor token") + } + + return decorateGoE(c, c.totalEnergy, phases), nil } return c, nil } -func (c *GoE) localResponse(function, payload string) (goeStatusResponse, error) { - var status goeStatusResponse - - url := fmt.Sprintf("%s/%s", c.uri, function) - if payload != "" { - url += "?payload=" + payload - } - - err := c.GetJSON(url, &status) - return status, err -} - -func (c *GoE) cloudResponse(function, payload string) (goeStatusResponse, error) { - var status goeCloudResponse - - url := fmt.Sprintf("%s/%s?token=%s", goeCloud, function, c.token) - if payload != "" { - url += "&payload=" + payload - } - - err := c.GetJSON(url, &status) - if err == nil && status.Success != nil && !*status.Success { - err = errors.New(status.Error) - } - - return status.Data, err -} - -func (c *GoE) apiStatus() (status goeStatusResponse, err error) { - if c.token == "" { - return c.localResponse("status", "") - } - - if time.Since(c.updated) >= c.cache { - status, err = c.cloudResponse("api_status", "") - if err == nil { - c.updated = time.Now() - c.status = status - } - } - - return c.status, err -} - -// apiUpdate invokes either cloud or local api -// goeStatusResponse is only valid for local api. Use Fwv if valid. -func (c *GoE) apiUpdate(payload string) (goeStatusResponse, error) { - if c.token == "" { - return c.localResponse("mqtt", payload) - } - - status, err := c.cloudResponse("api", payload) - if err == nil { - // cloud api sends partial response, don't store it as status - c.updated = time.Time{} - } - - return status, err -} - // Status implements the api.Charger interface func (c *GoE) Status() (api.ChargeStatus, error) { - status, err := c.apiStatus() + resp, err := c.api.Status() if err != nil { return api.StatusNone, err } - switch status.Car { + switch car := resp.Status(); car { case 1: return api.StatusA, nil case 2: @@ -194,25 +89,18 @@ func (c *GoE) Status() (api.ChargeStatus, error) { case 3, 4: return api.StatusB, nil default: - return api.StatusNone, fmt.Errorf("car unknown result: %d", status.Car) + return api.StatusNone, fmt.Errorf("car unknown result: %d", car) } } // Enabled implements the api.Charger interface func (c *GoE) Enabled() (bool, error) { - status, err := c.apiStatus() + resp, err := c.api.Status() if err != nil { return false, err } - switch status.Alw { - case 0: - return false, nil - case 1: - return true, nil - default: - return false, fmt.Errorf("alw unknown result: %d", status.Alw) - } + return resp.Enabled(), nil } // Enable implements the api.Charger interface @@ -221,52 +109,87 @@ func (c *GoE) Enable(enable bool) error { if enable { b = 1 } - _, err := c.apiUpdate(fmt.Sprintf("alw=%d", b)) - return err + + param := map[bool]string{false: "alw", true: "frc"}[c.api.IsV2()] + if c.api.IsV2() { + b += 1 + } + + return c.api.Update(fmt.Sprintf("%s=%d", param, b)) } // MaxCurrent implements the api.Charger interface func (c *GoE) MaxCurrent(current int64) error { - _, err := c.apiUpdate(fmt.Sprintf("amx=%d", current)) - return err + param := map[bool]string{false: "amx", true: "amp"}[c.api.IsV2()] + return c.api.Update(fmt.Sprintf("%s=%d", param, current)) } var _ api.Meter = (*GoE)(nil) // CurrentPower implements the api.Meter interface func (c *GoE) CurrentPower() (float64, error) { - status, err := c.apiStatus() - var power float64 - if len(status.Nrg) == 16 { - power = float64(status.Nrg[11]) * 10 + resp, err := c.api.Status() + if err != nil { + return 0, err } - return power, err + + return resp.CurrentPower(), err } var _ api.ChargeRater = (*GoE)(nil) // ChargedEnergy implements the api.ChargeRater interface func (c *GoE) ChargedEnergy() (float64, error) { - status, err := c.apiStatus() - energy := float64(status.Dws) / 3.6e5 // Deka-Watt-Seconds to kWh (100.000 == 0,277kWh) - return energy, err + resp, err := c.api.Status() + if err != nil { + return 0, err + } + + return resp.ChargedEnergy(), err } var _ api.MeterCurrent = (*GoE)(nil) // Currents implements the api.MeterCurrent interface func (c *GoE) Currents() (float64, float64, float64, error) { - status, err := c.apiStatus() - if len(status.Nrg) == 16 { - return float64(status.Nrg[4]) / 10, float64(status.Nrg[5]) / 10, float64(status.Nrg[6]) / 10, nil + resp, err := c.api.Status() + if err != nil { + return 0, 0, 0, err } - return 0, 0, 0, err + + i1, i2, i3 := resp.Currents() + + return i1, i2, i3, err } var _ api.Identifier = (*GoE)(nil) // Identify implements the api.Identifier interface func (c *GoE) Identify() (string, error) { - status, err := c.apiStatus() - return status.RFIDName(), err + resp, err := c.api.Status() + return resp.Identify(), err +} + +// totalEnergy implements the api.MeterEnergy interface - v2 only +func (c *GoE) totalEnergy() (float64, error) { + resp, err := c.api.Status() + if err != nil { + return 0, err + } + + var val float64 + if res, ok := resp.(*goe.StatusResponse2); ok { + val = res.TotalEnergy() + } + + return val, err +} + +// phases1p3p implements the api.ChargePhases interface - v2 only +func (c *GoE) phases1p3p(phases int) error { + if phases == 3 { + phases = 2 + } + + return c.api.Update(fmt.Sprintf("psm=%d", phases)) } diff --git a/charger/go-e/api.go b/charger/go-e/api.go new file mode 100644 index 000000000..df2c7a5b5 --- /dev/null +++ b/charger/go-e/api.go @@ -0,0 +1,160 @@ +package goe + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" +) + +const CloudURI = "https://api.go-e.co" + +// Response is the v1 and v2 api response interface +type Response interface { + Status() int + Enabled() bool + CurrentPower() float64 + ChargedEnergy() float64 + Currents() (float64, float64, float64) + Identify() string +} + +type UpdateResponse map[string]interface{} + +type API interface { + IsV2() bool + Status() (Response, error) + Update(payload string) error +} + +type LocalAPI struct { + *request.Helper + uri string + v2 bool +} + +var _ API = (*LocalAPI)(nil) + +func NewLocal(log *util.Logger, uri string) *LocalAPI { + uri = strings.TrimRight(uri, "/") + uri = strings.TrimSuffix(uri, "/api") + + api := &LocalAPI{ + Helper: request.NewHelper(log), + uri: uri, + } + + api.upgradeV2() + + return api +} + +// upgradeV2 will switch to use the v2 api and revert if not available +func (c *LocalAPI) upgradeV2() { + c.v2 = true // use v2 response struct + + res := new(StatusResponse2) + err := c.response("api/status?filter=alw", &res) + + if err == nil { + c.uri = c.uri + "/api" + } else { + c.v2 = false + } +} + +// IsV2 returns v2 api usage +func (c *LocalAPI) IsV2() bool { + return c.v2 +} + +// response returns a v1/v2 api response +func (c *LocalAPI) response(partial string, res interface{}) error { + url := fmt.Sprintf("%s/%s", c.uri, partial) + return c.GetJSON(url, &res) +} + +// Status reads a v1/v2 api response +func (c *LocalAPI) Status() (Response, error) { + if c.v2 { + res := new(StatusResponse2) + err := c.response("status?filter=alw,car,eto,nrg,wh,trx,cards", &res) + return res, err + } + + res := new(StatusResponse) + err := c.response("status", &res) + return res, err +} + +// Update executes a v1/v2 api update and returns the response +func (c *LocalAPI) Update(payload string) error { + res := new(UpdateResponse) + + if c.v2 { + err := c.response(fmt.Sprintf("set?%s", payload), &res) + return err + } + + err := c.response(fmt.Sprintf("mqtt?payload=%s", payload), &res) + return err +} + +type cloud struct { + *request.Helper + token string + cache time.Duration + updated time.Time + status Response +} + +var _ API = (*cloud)(nil) + +func NewCloud(log *util.Logger, token string, cache time.Duration) API { + return &cloud{ + Helper: request.NewHelper(log), + token: token, + cache: cache, + } +} + +func (c *cloud) IsV2() bool { + return false +} + +func (c *cloud) response(function, payload string) (*StatusResponse, error) { + var status CloudResponse + + url := fmt.Sprintf("%s/%s?token=%s", CloudURI, function, c.token) + if payload != "" { + url += "&payload=" + payload + } + + err := c.GetJSON(url, &status) + if err == nil && status.Success != nil && !*status.Success { + err = errors.New(status.Error) + } + + return &status.Data, err +} + +func (c *cloud) Status() (status Response, err error) { + if time.Since(c.updated) >= c.cache { + status, err = c.response("api_status", "") + if err == nil { + c.updated = time.Now() + c.status = status + } + } + + return c.status, err +} + +func (c *cloud) Update(payload string) error { + c.updated = time.Time{} + _, err := c.response("api", payload) + return err +} diff --git a/charger/go-e/api_test.go b/charger/go-e/api_test.go new file mode 100644 index 000000000..b96ebc9c7 --- /dev/null +++ b/charger/go-e/api_test.go @@ -0,0 +1,68 @@ +package goe + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/evcc-io/evcc/util" +) + +type handler struct { + uri string +} + +func (h *handler) expect(uri string) { + h.uri = uri +} + +func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + if r.URL.RawQuery != "" { + path += "?" + r.URL.RawQuery + } + fmt.Println(path) + + if path == h.uri { + fmt.Fprint(w, "{}") + } else { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, "expected %s", h.uri) + } +} + +func TestLocalV1(t *testing.T) { + h := &handler{} + srv := httptest.NewServer(h) + + // h.expect("/api/status?filter=alw") + local := NewLocal(util.NewLogger("foo"), srv.URL) + + h.expect("/status") + if _, err := local.Status(); err != nil { + t.Error(err) + } + + h.expect("/mqtt?payload=foo=bar") + if err := local.Update("foo=bar"); err != nil { + t.Error(err) + } +} +func TestLocalV2(t *testing.T) { + h := &handler{} + srv := httptest.NewServer(h) + + h.expect("/api/status?filter=alw") + local := NewLocal(util.NewLogger("foo"), srv.URL) + + h.expect("/api/status?filter=alw,car,eto,nrg,wh,trx,cards") + if _, err := local.Status(); err != nil { + t.Error(err) + } + + h.expect("/api/set?foo=bar") + if err := local.Update("foo=bar"); err != nil { + t.Error(err) + } +} diff --git a/charger/go-e/types.go b/charger/go-e/types.go new file mode 100644 index 000000000..b46b4769e --- /dev/null +++ b/charger/go-e/types.go @@ -0,0 +1,86 @@ +package goe + +// CloudResponse is the cloud API response +type CloudResponse struct { + Success *bool // only valid for cloud payload commands + Age int + Error string // only valid for cloud payload commands + Data StatusResponse +} + +// StatusResponse is the API response if status not OK +type StatusResponse struct { + Fwv string `json:"fwv"` // firmware version + Car int `json:"car,string"` // car status + Alw int `json:"alw,string"` // allow charging + Amp int `json:"amp,string"` // current [A] + Err int `json:"err,string"` // error + Stp int `json:"stp,string"` // stop state + Tmp int `json:"tmp,string"` // temperature [°C] + Dws int `json:"dws,string"` // energy [Ws] + Nrg []float64 `json:"nrg"` // voltage, current, power + Uby int `json:"uby,string"` // unlocked_by + Rna string `json:"rna"` // RFID 1 + Rnm string `json:"rnm"` // RFID 2 + Rne string `json:"rne"` // RFID 3 + Rn4 string `json:"rn4"` // RFID 4 + Rn5 string `json:"rn5"` // RFID 5 + Rn6 string `json:"rn6"` // RFID 6 + Rn7 string `json:"rn7"` // RFID 7 + Rn8 string `json:"rn8"` // RFID 8 + Rn9 string `json:"rn9"` // RFID 9 + Rn1 string `json:"rn1"` // RFID 10 +} + +func (g *StatusResponse) Status() int { + return g.Car +} + +func (g *StatusResponse) Enabled() bool { + return g.Alw == 1 +} + +func (g *StatusResponse) CurrentPower() float64 { + if len(g.Nrg) == 16 { + return g.Nrg[11] * 10 + } + return 0 +} + +func (g *StatusResponse) ChargedEnergy() float64 { + return float64(g.Dws) / 3.6e5 // Deka-Watt-Seconds to kWh (100.000 == 0,277kWh) +} + +func (g *StatusResponse) Currents() (float64, float64, float64) { + if len(g.Nrg) == 16 { + return g.Nrg[4] / 10, g.Nrg[5] / 10, g.Nrg[6] / 10 + } + return 0, 0, 0 +} + +func (g *StatusResponse) Identify() string { + switch g.Uby { + case 1: + return g.Rna + case 2: + return g.Rnm + case 3: + return g.Rne + case 4: + return g.Rn4 + case 5: + return g.Rn5 + case 6: + return g.Rn6 + case 7: + return g.Rn7 + case 8: + return g.Rn8 + case 9: + return g.Rn9 + case 10: + return g.Rn1 + default: + return "" + } +} diff --git a/charger/go-e/types2.go b/charger/go-e/types2.go new file mode 100644 index 000000000..a835a8bb0 --- /dev/null +++ b/charger/go-e/types2.go @@ -0,0 +1,65 @@ +package goe + +// StatusResponse2 is the v2 API response +type StatusResponse2 struct { + Fwv string // firmware version + Car int // car status + Alw bool // allow charging + Amp int // current [A] + Err int // error + Eto uint64 // energy total Wh + Psm int // phase switching + Stp int // stop state + Tmp int // temperature [°C] + Trx int // transaction + Nrg []float64 // voltage, current, power + Wh float64 // energy [Wh] + Cards []Card // RFID cards +} + +// Card is the v2 RFID card +type Card struct { + Name string + Energy int + CardID bool +} + +func (g *StatusResponse2) Status() int { + return g.Car +} + +func (g *StatusResponse2) Enabled() bool { + return g.Alw +} + +func (g *StatusResponse2) CurrentPower() float64 { + if len(g.Nrg) == 16 { + return g.Nrg[11] + } + + return 0 +} + +func (g *StatusResponse2) ChargedEnergy() float64 { + return g.Wh / 1e3 +} + +func (g *StatusResponse2) TotalEnergy() float64 { + return float64(g.Eto) / 1e3 +} + +func (g *StatusResponse2) Currents() (float64, float64, float64) { + if len(g.Nrg) == 16 { + return g.Nrg[4], g.Nrg[5], g.Nrg[6] + } + + return 0, 0, 0 +} + +func (g *StatusResponse2) Identify() string { + if g.Trx > 0 { + return g.Cards[g.Trx-1].Name + } + + return "" +} diff --git a/charger/go-e_decorators.go b/charger/go-e_decorators.go new file mode 100644 index 000000000..0d3f27469 --- /dev/null +++ b/charger/go-e_decorators.go @@ -0,0 +1,69 @@ +package charger + +// Code generated by github.com/andig/cmd/tools/decorate.go. DO NOT EDIT. + +import ( + "github.com/evcc-io/evcc/api" +) + +func decorateGoE(base *GoE, meterEnergy func() (float64, error), chargePhases func(phases int) error) api.Charger { + switch { + case chargePhases == nil && meterEnergy == nil: + return base + + case chargePhases == nil && meterEnergy != nil: + return &struct { + *GoE + api.MeterEnergy + }{ + GoE: base, + MeterEnergy: &decorateGoEMeterEnergyImpl{ + meterEnergy: meterEnergy, + }, + } + + case chargePhases != nil && meterEnergy == nil: + return &struct { + *GoE + api.ChargePhases + }{ + GoE: base, + ChargePhases: &decorateGoEChargePhasesImpl{ + chargePhases: chargePhases, + }, + } + + case chargePhases != nil && meterEnergy != nil: + return &struct { + *GoE + api.ChargePhases + api.MeterEnergy + }{ + GoE: base, + ChargePhases: &decorateGoEChargePhasesImpl{ + chargePhases: chargePhases, + }, + MeterEnergy: &decorateGoEMeterEnergyImpl{ + meterEnergy: meterEnergy, + }, + } + } + + return nil +} + +type decorateGoEChargePhasesImpl struct { + chargePhases func(int) error +} + +func (impl *decorateGoEChargePhasesImpl) Phases1p3p(phases int) error { + return impl.chargePhases(phases) +} + +type decorateGoEMeterEnergyImpl struct { + meterEnergy func() (float64, error) +} + +func (impl *decorateGoEMeterEnergyImpl) TotalEnergy() (float64, error) { + return impl.meterEnergy() +} diff --git a/charger/go-e_test.go b/charger/go-e_test.go new file mode 100644 index 000000000..197f67748 --- /dev/null +++ b/charger/go-e_test.go @@ -0,0 +1,89 @@ +package charger + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util/sponsor" +) + +type handler struct { + uri string +} + +func (h *handler) expect(uri string) { + h.uri = uri +} + +func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + if r.URL.RawQuery != "" { + path += "?" + r.URL.RawQuery + } + fmt.Println(path) + + if path == h.uri { + fmt.Fprint(w, "{}") + } else { + w.WriteHeader(http.StatusInternalServerError) + fmt.Fprintf(w, "expected %s", h.uri) + } +} + +func TestGoEV1(t *testing.T) { + h := &handler{} + srv := httptest.NewServer(h) + + wb, err := NewGoE(srv.URL, "", 0) + if err != nil { + t.Error(err) + } + + if _, ok := wb.(api.Meter); !ok { + t.Error("missing Meter api") + } + + if _, ok := wb.(api.MeterCurrent); !ok { + t.Error("missing MeterCurrent api") + } + + if _, ok := wb.(api.Identifier); !ok { + t.Error("missing Identifier api") + } +} + +func TestGoEV2(t *testing.T) { + h := &handler{} + srv := httptest.NewServer(h) + + sponsor.Subject = "foo" + + h.expect("/api/status?filter=alw") + wb, err := NewGoE(srv.URL, "", 0) + if err != nil { + t.Error(err) + } + + if _, ok := wb.(api.Meter); !ok { + t.Error("missing Meter api") + } + + if _, ok := wb.(api.MeterCurrent); !ok { + t.Error("missing MeterCurrent api") + } + + if _, ok := wb.(api.Identifier); !ok { + t.Error("missing Identifier api") + } + + if _, ok := wb.(api.MeterEnergy); !ok { + t.Error("missing MeterEnergy api") + } + + if _, ok := wb.(api.ChargePhases); !ok { + t.Error("missing ChargePhases api") + } +}