Add Smart EQ (#2112)

This commit is contained in:
andig 2021-12-28 20:23:12 +01:00
parent 8577fa1ae9
commit c8204b3cac
6 changed files with 443 additions and 1 deletions

View file

@ -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)

150
vehicle/mb/identity.go Normal file
View file

@ -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
}

66
vehicle/smart.go Normal file
View file

@ -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
}

103
vehicle/smart/api.go Normal file
View file

@ -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
}

78
vehicle/smart/provider.go Normal file
View file

@ -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
}

44
vehicle/smart/types.go Normal file
View file

@ -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
}