From c8204b3cac70faca9f1ebc3954530bed15fec05e Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 28 Dec 2021 20:23:12 +0100 Subject: [PATCH] Add Smart EQ (#2112) --- README.md | 3 +- vehicle/mb/identity.go | 150 ++++++++++++++++++++++++++++++++++++++ vehicle/smart.go | 66 +++++++++++++++++ vehicle/smart/api.go | 103 ++++++++++++++++++++++++++ vehicle/smart/provider.go | 78 ++++++++++++++++++++ vehicle/smart/types.go | 44 +++++++++++ 6 files changed, 443 insertions(+), 1 deletion(-) create mode 100644 vehicle/mb/identity.go create mode 100644 vehicle/smart.go create mode 100644 vehicle/smart/api.go create mode 100644 vehicle/smart/provider.go create mode 100644 vehicle/smart/types.go diff --git a/README.md b/README.md index 5e5b80021..0aa6db590 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ EVCC is an extensible EV Charge Controller with PV integration implemented in [G - Build-your-own: Phoenix (includes ESL Walli), [SimpleEVSE](https://www.evse-wifi.de/produkt-schlagwort/simple-evse-wb/) - Smart-Home outlets: FritzDECT, Shelly, Tasmota, TP-Link - multiple [meters](#meter): ModBus (Eastron SDM, MPM3PM, SBC ALE3 and many more), Discovergy (using HTTP plugin), SMA Sunny Home Manager and Energy Meter, KOSTAL Smart Energy Meter (KSEM, EMxx), any Sunspec-compatible inverter or home battery devices (Fronius, SMA, SolarEdge, KOSTAL, STECA, E3DC, ...), Tesla PowerWall, LG ESS HOME -- wide support of vendor-specific [vehicles](#vehicle) interfaces (remote charge, battery and preconditioning status): Audi, BMW, Fiat, Ford, Hyundai, Kia, Mini, Nissan, Niu, Porsche, Renault, Seat, Skoda, Tesla, Volkswagen, Volvo, Tronity +- wide support of vendor-specific [vehicles](#vehicle) interfaces (remote charge, battery and preconditioning status): Audi, BMW, Fiat, Ford, Hyundai, Kia, Mini, Nissan, Niu, Porsche, Renault, Seat, Smart, Skoda, Tesla, Volkswagen, Volvo, Tronity - [plugins](#plugins) for integrating with any charger/ meter/ vehicle: Modbus (meters and grid inverters), HTTP, MQTT, Javascript, WebSockets and shell scripts - status [notifications](#notifications) using [Telegram](https://telegram.org), [PushOver](https://pushover.net) and [many more](https://containrrr.dev/shoutrrr/) - logging using [InfluxDB](https://www.influxdata.com) and [Grafana](https://grafana.com/grafana/) @@ -310,6 +310,7 @@ Available vehicle remote interface implementations are: - `ovms`: Open Vehicle Monitoring System (f.i. Twizzy, Smart ED) - `porsche`: Porsche (Taycan, Cayenne E-Hybrid) - `seat`: Seat (Cupra, Mii) +- `smart`: Smart (EQ) - `skoda`: Skoda (Citigo) - `enyaq`: Skoda (Enyaq) - `vw`: Volkswagen (eGolf, eUp) diff --git a/vehicle/mb/identity.go b/vehicle/mb/identity.go new file mode 100644 index 000000000..28b7c0c52 --- /dev/null +++ b/vehicle/mb/identity.go @@ -0,0 +1,150 @@ +package mb + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "strings" + + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + cv "github.com/nirasan/go-oauth-pkce-code-verifier" + "golang.org/x/net/context" + "golang.org/x/net/publicsuffix" + "golang.org/x/oauth2" +) + +// https://github.com/TA2k/ioBroker.smart-eq + +const OAuthURI = "https://id.mercedes-benz.com" + +type Identity struct { + *request.Helper + oc *oauth2.Config + oauth2.TokenSource +} + +// NewIdentity creates Mercedes Benz identity +func NewIdentity(log *util.Logger, oc *oauth2.Config) *Identity { + return &Identity{ + Helper: request.NewHelper(log), + oc: oc, + } +} + +// github.com/uhthomas/tesla +func state() string { + var b [9]byte + if _, err := io.ReadFull(rand.Reader, b[:]); err != nil { + panic(err) + } + return base64.RawURLEncoding.EncodeToString(b[:]) +} + +func (v *Identity) Login(user, password string) error { + if v.Client.Jar == nil { + var err error + v.Client.Jar, err = cookiejar.New(&cookiejar.Options{ + PublicSuffixList: publicsuffix.List, + }) + if err != nil { + return err + } + } + + cv, err := cv.CreateCodeVerifier() + if err != nil { + return err + } + + uri := v.oc.AuthCodeURL(state(), oauth2.AccessTypeOffline, + oauth2.SetAuthURLParam("code_challenge", cv.CodeChallengeS256()), + oauth2.SetAuthURLParam("code_challenge_method", "S256"), + ) + + var resume string + if err == nil { + var param request.InterceptResult + v.Client.CheckRedirect, param = request.InterceptRedirect("resume", false) + + if _, err = v.Get(uri); err == nil { + resume, err = param() + } + + v.Client.CheckRedirect = nil + } + + data := struct { + Username string `json:"username"` + Password string `json:"password,omitempty"` + RememberMe bool `json:"rememberMe,omitempty"` + }{ + Username: user, + } + + var res struct { + Result, Token string + Errors []struct{ Key string } + } + + var req *http.Request + if err == nil { + uri = fmt.Sprintf("%s/ciam/auth/login/user", OAuthURI) + req, err = request.New(http.MethodPost, uri, request.MarshalJSON(data), request.JSONEncoding) + if err == nil { + if err = v.DoJSON(req, &res); err != nil && len(res.Errors) > 0 { + err = fmt.Errorf("%s: %w", string(res.Errors[0].Key), err) + } + } + } + + if err == nil { + data.Password = password + data.RememberMe = true + + uri = fmt.Sprintf("%s/ciam/auth/login/pass", OAuthURI) + req, err = request.New(http.MethodPost, uri, request.MarshalJSON(data), request.JSONEncoding) + if err == nil { + if err = v.DoJSON(req, &res); err != nil && len(res.Errors) > 0 { + err = fmt.Errorf("%s: %w", string(res.Errors[0].Key), err) + } + } + } + + var code string + if err == nil { + params := url.Values{ + "token": {res.Token}, + } + + var param request.InterceptResult + v.Client.CheckRedirect, param = request.InterceptRedirect("code", true) + + uri := OAuthURI + resume + if _, err = v.Post(uri, request.FormContent, strings.NewReader(params.Encode())); err == nil { + code, err = param() + } + + v.Client.CheckRedirect = nil + } + + var token *oauth2.Token + if err == nil { + ctx, cancel := context.WithTimeout(context.Background(), request.Timeout) + defer cancel() + + token, err = v.oc.Exchange(ctx, code, + oauth2.SetAuthURLParam("code_verifier", cv.CodeChallengePlain()), + ) + } + + if err == nil { + v.TokenSource = v.oc.TokenSource(context.Background(), token) + } + + return err +} diff --git a/vehicle/smart.go b/vehicle/smart.go new file mode 100644 index 000000000..f7e822d2e --- /dev/null +++ b/vehicle/smart.go @@ -0,0 +1,66 @@ +package vehicle + +import ( + "fmt" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/vehicle/mb" + "github.com/evcc-io/evcc/vehicle/smart" +) + +// Smart is an api.Vehicle implementation for Smart cars +type Smart struct { + *embed + *smart.Provider +} + +func init() { + registry.Add("smart", NewSmartFromConfig) +} + +// NewSmartFromConfig creates a new vehicle +func NewSmartFromConfig(other map[string]interface{}) (api.Vehicle, error) { + cc := struct { + embed `mapstructure:",squash"` + User, Password string + VIN string + Expiry time.Duration + Cache time.Duration + }{ + Expiry: expiry, + Cache: interval, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + log := util.NewLogger("smart").Redact(cc.User, cc.Password, cc.VIN) + + v := &Smart{ + embed: &cc.embed, + } + + identity := mb.NewIdentity(log, smart.OAuth2Config) + err := identity.Login(cc.User, cc.Password) + if err != nil { + return v, fmt.Errorf("login failed: %w", err) + } + + api := smart.NewAPI(log, identity) + + if cc.VIN == "" { + cc.VIN, err = findVehicle(api.Vehicles()) + if err == nil { + log.DEBUG.Printf("found vehicle: %v", cc.VIN) + } + } + + if err == nil { + v.Provider = smart.NewProvider(log, api, cc.VIN, cc.Expiry, cc.Cache) + } + + return v, err +} diff --git a/vehicle/smart/api.go b/vehicle/smart/api.go new file mode 100644 index 000000000..6dba3cd71 --- /dev/null +++ b/vehicle/smart/api.go @@ -0,0 +1,103 @@ +package smart + +import ( + "fmt" + + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/util/transport" + "github.com/evcc-io/evcc/vehicle/mb" + "github.com/thoas/go-funk" + "golang.org/x/oauth2" +) + +// https://github.com/TA2k/ioBroker.smart-eq + +const ApiURI = "https://oneapp.microservice.smart.com/seqc/v0" + +// https://id.mercedes-benz.com/.well-known/openid-configuration +var OAuth2Config = &oauth2.Config{ + ClientID: "70d89501-938c-4bec-82d0-6abb550b0825", + RedirectURL: "https://oneapp.microservice.smart.com", + Endpoint: oauth2.Endpoint{ + AuthURL: mb.OAuthURI + "/as/authorization.oauth2", + TokenURL: mb.OAuthURI + "/as/token.oauth2", + }, + Scopes: []string{"openid", "profile", "email", "phone", "ciam-uid", "offline_access"}, +} + +type API struct { + *request.Helper +} + +func NewAPI(log *util.Logger, identity oauth2.TokenSource) *API { + v := &API{ + Helper: request.NewHelper(log), + } + + // replace client transport with authenticated transport + v.Client.Transport = &transport.Decorator{ + Base: &oauth2.Transport{ + Source: identity, + Base: v.Client.Transport, + }, + Decorator: transport.DecorateHeaders(map[string]string{ + "accept": "*/*", + "accept-language": "de-DE;q=1.0", + "guid": "280C6B55-F179-4428-88B6-E0CCF5C22A7C", + "x-applicationname": OAuth2Config.ClientID, + }), + } + + return v +} + +func (v *API) Vehicles() ([]string, error) { + type vehicle struct { + FIN string + } + + var res struct { + Authorizations, LicensePlates []vehicle + Error string + ErrorDescription string `json:"error_description"` + } + + uri := fmt.Sprintf("%s/users/current", ApiURI) + err := v.GetJSON(uri, &res) + if err != nil && res.Error != "" { + err = fmt.Errorf("%s (%s): %w", res.Error, res.ErrorDescription, err) + } + + vehicles := funk.Map(res.LicensePlates, func(v vehicle) string { + return v.FIN + }).([]string) + + return vehicles, err +} + +func (v *API) Status(vin string) (StatusResponse, error) { + var res StatusResponse + + uri := fmt.Sprintf("%s/vehicles/%s/init-data?requestedData=BOTH&countryCode=DE&locale=de-DE", ApiURI, vin) + err := v.GetJSON(uri, &res) + + if err != nil && res.Error != "" { + err = fmt.Errorf("%s (%s): %w", res.Error, res.ErrorDescription, err) + } + + return res, err +} + +func (v *API) Refresh(vin string) (StatusResponse, error) { + var res StatusResponse + + uri := fmt.Sprintf("%s/vehicles/%s/refresh-data", ApiURI, vin) + err := v.GetJSON(uri, &res) + + if err != nil && res.Error != "" { + err = fmt.Errorf("%s (%s): %w", res.Error, res.ErrorDescription, err) + } + + return res, err +} diff --git a/vehicle/smart/provider.go b/vehicle/smart/provider.go new file mode 100644 index 000000000..1a67c9d0f --- /dev/null +++ b/vehicle/smart/provider.go @@ -0,0 +1,78 @@ +package smart + +import ( + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/provider" + "github.com/evcc-io/evcc/util" +) + +// https://github.com/TA2k/ioBroker.smart-eq + +type Provider struct { + statusG func() (interface{}, error) + expiry time.Duration +} + +func NewProvider(log *util.Logger, api *API, vin string, expiry, cache time.Duration) *Provider { + v := &Provider{ + expiry: expiry, + } + + v.statusG = provider.NewCached(func() (interface{}, error) { + return v.status( + func() (StatusResponse, error) { return api.Status(vin) }, + func() (StatusResponse, error) { return api.Refresh(vin) }, + ) + }, cache).InterfaceGetter() + + return v +} + +func (v *Provider) status(statusG func() (StatusResponse, error), refreshG func() (StatusResponse, error)) (StatusResponse, error) { + res, err := statusG() + + // if err == nil && res.Status.StatusData.Soc.Ts.Time.Add(v.expiry).Before(time.Now()) { + // res, err = refreshG() + // } + + return res, err +} + +// SoC implements the api.Vehicle interface +func (v *Provider) SoC() (float64, error) { + res, err := v.statusG() + + if res, ok := res.(StatusResponse); err == nil && ok { + return res.Status.StatusData.Soc.Value, nil + } + + return 0, err +} + +var _ api.VehicleRange = (*Provider)(nil) + +// Range implements the api.VehicleRange interface +func (v *Provider) Range() (int64, error) { + res, err := v.statusG() + + if res, ok := res.(StatusResponse); err == nil && ok { + return int64(res.Status.StatusData.RangeElectric.Value), nil + } + + return 0, err +} + +var _ api.VehicleOdometer = (*Provider)(nil) + +// Odometer implements the Provider.VehicleOdometer interface +func (v *Provider) Odometer() (float64, error) { + res, err := v.statusG() + + if res, ok := res.(StatusResponse); err == nil && ok { + return res.Status.StatusData.Odo.Value, nil + } + + return 0, err +} diff --git a/vehicle/smart/types.go b/vehicle/smart/types.go new file mode 100644 index 000000000..d42c3f1ad --- /dev/null +++ b/vehicle/smart/types.go @@ -0,0 +1,44 @@ +package smart + +import ( + "strconv" + "time" +) + +type StatusResponse struct { + PreCond struct{} + ChargeOpt struct{} + Status struct { + StatusData struct { + Odo Value + RangeElectric Value + Soc Value + } `json:"data"` + } + Images []string + Error string + ErrorDescription string `json:"error_description"` +} + +type Value struct { + Status int + Value float64 + Ts TimeSecs +} + +// TimeSecs implements JSON unmarshal for Unix timestamps in seconds +type TimeSecs struct { + time.Time +} + +// UnmarshalJSON decodes unix timestamps in ms into time.Time +func (ct *TimeSecs) UnmarshalJSON(data []byte) error { + i, err := strconv.ParseInt(string(data), 10, 64) + + if err == nil { + t := time.Unix(i, 0) + (*ct).Time = t + } + + return err +}