Add Smart EQ (#2112)
This commit is contained in:
parent
8577fa1ae9
commit
c8204b3cac
6 changed files with 443 additions and 1 deletions
|
|
@ -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
150
vehicle/mb/identity.go
Normal 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
66
vehicle/smart.go
Normal 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
103
vehicle/smart/api.go
Normal 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
78
vehicle/smart/provider.go
Normal 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
44
vehicle/smart/types.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue