From d4f8d03cb9ac9cddcf7b4d20efeb3a517d957ef6 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 28 Feb 2021 14:06:05 +0100 Subject: [PATCH] Add Peugeot/Opel/Citroen api (#692) --- README.md | 2 + api/api.go | 10 +-- go.mod | 1 + go.sum | 2 + vehicle/id/api.go | 9 ++- vehicle/psa.go | 93 ++++++++++++++++++++++ vehicle/psa/api.go | 167 ++++++++++++++++++++++++++++++++++++++++ vehicle/psa/duration.go | 36 +++++++++ vehicle/psa/provider.go | 103 +++++++++++++++++++++++++ 9 files changed, 415 insertions(+), 8 deletions(-) create mode 100644 vehicle/psa.go create mode 100644 vehicle/psa/api.go create mode 100644 vehicle/psa/duration.go create mode 100644 vehicle/psa/provider.go diff --git a/README.md b/README.md index 542bd7d13..0d03d0f59 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ EVCC is an extensible EV Charge Controller with PV integration implemented in [G - [MQTT API](#mqtt-api) - [Background](#background) + ## Getting started 1. Install EVCC. For details see [installation](#installation). @@ -240,6 +241,7 @@ Available vehicle remote interface implementations are: - `audi`: Audi (eTron, Q55) - `bmw`: BMW (i3) +- `citroen`, `opel`, `peugeot`: Follow this [tutorial](https://github.com/flobz/psa_car_controller) to obtain client credentials for PSA. - `ford`: Ford (Kuga, Mustang) - `kia`: Kia (Bluelink vehicles like Soul 2019) **temporarily removed due to incompatible api changes** - `hyundai`: Hyundai (Bluelink vehicles like Kona or Ioniq) **temporarily removed due to incompatible api changes** diff --git a/api/api.go b/api/api.go index 967a38829..0dd8b9903 100644 --- a/api/api.go +++ b/api/api.go @@ -107,12 +107,12 @@ type VehicleRange interface { Range() (int64, error) } -// VehicleStartCharge starts the charging session on the vehicle side -type VehicleStartCharge interface { - StartCharge() error -} - // VehicleClimater provides climatisation data type VehicleClimater interface { Climater() (active bool, outsideTemp float64, targetTemp float64, err error) } + +// VehicleStartCharge starts the charging session on the vehicle side +type VehicleStartCharge interface { + StartCharge() error +} diff --git a/go.mod b/go.mod index 054ddd6a2..64321bb0a 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/bogosj/tesla v0.0.0-20210226163712-3a995277d27d github.com/containrrr/shoutrrr v0.4.0 github.com/denisbrodbeck/machineid v1.0.1 + github.com/dylanmei/iso8601 v0.1.0 github.com/eclipse/paho.mqtt.golang v1.3.1 github.com/fatih/structs v1.1.0 github.com/go-ping/ping v0.0.0-20201022122018-3977ed72668a diff --git a/go.sum b/go.sum index 789c29318..0696c0939 100644 --- a/go.sum +++ b/go.sum @@ -94,6 +94,8 @@ github.com/deepmap/oapi-codegen v1.4.1/go.mod h1:1jY0YDxfBF3tXk1u3sARJMSUJa9wV0U github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ= github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dylanmei/iso8601 v0.1.0 h1:812NGQDBcqquTfH5Yeo7lwR0nzx/cKdsmf3qMjPURUI= +github.com/dylanmei/iso8601 v0.1.0/go.mod h1:w9KhXSgIyROl1DefbMYIE7UVSIvELTbMrCfx+QkYnoQ= github.com/eclipse/paho.mqtt.golang v1.2.0/go.mod h1:H9keYFcgq3Qr5OUJm/JZI/i6U7joQ8SYLhZwfeOo6Ts= github.com/eclipse/paho.mqtt.golang v1.3.0/go.mod h1:eTzb4gxwwyWpqBUHGQZ4ABAV7+Jgm1PklsYT/eo8Hcc= github.com/eclipse/paho.mqtt.golang v1.3.1 h1:6F5FYb1hxVSZS+p0ji5xBQamc5ltOolTYRy5R15uVmI= diff --git a/vehicle/id/api.go b/vehicle/id/api.go index 31b5a245f..57ad09603 100644 --- a/vehicle/id/api.go +++ b/vehicle/id/api.go @@ -13,6 +13,9 @@ import ( // https://identity-userinfo.vwgroup.io/oidc/userinfo // https://customer-profile.apps.emea.vwapps.io/v1/customers//realCarData +// BaseURL is the API base url +const BaseURL = "https://mobileapi.apps.emea.vwapps.io" + // API is an api.Vehicle implementation for VW ID cars type API struct { *request.Helper @@ -42,7 +45,7 @@ func NewAPI(log *util.Logger, identity *vw.Identity) *API { // Vehicles implements the /vehicles response func (v *API) Vehicles() (res []string, err error) { - uri := "https://mobileapi.apps.emea.vwapps.io/vehicles" + uri := fmt.Sprintf("%s/vehicles", BaseURL) req, err := request.New(http.MethodGet, uri, nil, map[string]string{ "Accept": "application/json", @@ -146,7 +149,7 @@ type RangeStatus struct { // Status implements the /status response func (v *API) Status(vin string) (res Status, err error) { - uri := fmt.Sprintf("https://mobileapi.apps.emea.vwapps.io/vehicles/%s/status", vin) + uri := fmt.Sprintf("%s/vehicles/%s/status", BaseURL, vin) req, err := request.New(http.MethodGet, uri, nil, map[string]string{ "Accept": "application/json", @@ -162,7 +165,7 @@ func (v *API) Status(vin string) (res Status, err error) { // Action implements vehicle actions func (v *API) Action(vin, action, value string) error { - uri := fmt.Sprintf("https://mobileapi.apps.emea.vwapps.io/vehicles/%s/%s/%s", vin, action, value) + uri := fmt.Sprintf("%s/vehicles/%s/%s/%s", BaseURL, vin, action, value) req, err := request.New(http.MethodPost, uri, nil, map[string]string{ "Accept": "application/json", diff --git a/vehicle/psa.go b/vehicle/psa.go new file mode 100644 index 000000000..d46fc4057 --- /dev/null +++ b/vehicle/psa.go @@ -0,0 +1,93 @@ +package vehicle + +import ( + "errors" + "strings" + "time" + + "github.com/andig/evcc/api" + "github.com/andig/evcc/util" + "github.com/andig/evcc/vehicle/psa" +) + +// https://github.com/flobz/psa_car_controller + +func init() { + registry.Add("citroen", NewCitroenFromConfig) + registry.Add("opel", NewOpelFromConfig) + registry.Add("peugeot", NewPeugeotFromConfig) +} + +// NewCitroenFromConfig creates a new vehicle +func NewCitroenFromConfig(other map[string]interface{}) (api.Vehicle, error) { + log := util.NewLogger("citroen") + return newPSA(log, "citroen.com", "clientsB2CCitroen", other) +} + +// NewOpelFromConfig creates a new vehicle +func NewOpelFromConfig(other map[string]interface{}) (api.Vehicle, error) { + log := util.NewLogger("opel") + return newPSA(log, "opel.com", "clientsB2COpel", other) +} + +// NewPeugeotFromConfig creates a new vehicle +func NewPeugeotFromConfig(other map[string]interface{}) (api.Vehicle, error) { + log := util.NewLogger("peugeot") + return newPSA(log, "peugeot.com", "clientsB2CPeugeot", other) +} + +// PSA is an api.Vehicle implementation for PSA cars +type PSA struct { + *embed + *psa.Provider // provides the api implementations +} + +// newPSA creates a new vehicle +func newPSA(log *util.Logger, brand, realm string, other map[string]interface{}) (api.Vehicle, error) { + cc := struct { + Title string + Capacity int64 + ClientID, ClientSecret string + User, Password, VIN string + Cache time.Duration + }{ + Cache: interval, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + v := &PSA{ + embed: &embed{cc.Title, cc.Capacity}, + } + + api := psa.NewAPI(log, brand, realm, cc.ClientID, cc.ClientSecret) + err := api.Login(cc.User, cc.Password) + + var vehicles []psa.Vehicle + if err == nil { + vehicles, err = api.Vehicles() + } + + var vid string + if err == nil { + if cc.VIN == "" && len(vehicles) == 1 { + vid = vehicles[0].ID + } else { + for _, vehicle := range vehicles { + if vehicle.VIN == strings.ToUpper(cc.VIN) { + vid = vehicle.ID + } + } + } + } + + if vid == "" { + return nil, errors.New("vin not found") + } + + v.Provider = psa.NewProvider(api, vid, cc.Cache) + + return v, err +} diff --git a/vehicle/psa/api.go b/vehicle/psa/api.go new file mode 100644 index 000000000..5bddf41af --- /dev/null +++ b/vehicle/psa/api.go @@ -0,0 +1,167 @@ +package psa + +import ( + "encoding/base64" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/andig/evcc/util" + "github.com/andig/evcc/util/request" + "golang.org/x/oauth2" +) + +// https://github.com/flobz/psa_car_controller +// https://developer.groupe-psa.io/webapi/b2c/api-reference/specification + +// BaseURL is the API base url +const BaseURL = "https://api.groupe-psa.com/connectedcar/v4" + +// API is an api.Vehicle implementation for PSA cars +type API struct { + *request.Helper + brand, realm string + id, secret string // client + token oauth2.Token +} + +// NewAPI creates a new vehicle +func NewAPI(log *util.Logger, brand, realm, id, secret string) *API { + v := &API{ + Helper: request.NewHelper(log), + brand: brand, + realm: realm, + id: id, + secret: secret, + } + return v +} + +// Login performs the login +func (v *API) Login(user, password string) error { + data := url.Values{ + "realm": []string{v.realm}, + "scope": []string{"openid profile"}, + "grant_type": []string{"password"}, + "username": []string{user}, + "password": []string{password}, + } + + uri := fmt.Sprintf("https://idpcvs.%s/am/oauth2/access_token", v.brand) + auth := fmt.Sprintf("%s:%s", v.id, v.secret) + + req, err := request.New(http.MethodPost, uri, strings.NewReader(data.Encode()), map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte(auth)), + }) + + if err == nil { + err = v.DoJSON(req, &v.token) + } + + fmt.Printf("\n%+v\n", v.token) + + return err +} + +// Vehicle is a single vehicle +type Vehicle struct { + ID string `json:"id"` + Label string `json:"label"` + Pictures []string `json:"pictures"` + VIN string `json:"vin"` +} + +// Vehicles implements the /vehicles response +func (v *API) Vehicles() ([]Vehicle, error) { + data := url.Values{ + "client_id": []string{v.id}, + } + + // BaseURL is the API base url + uri := fmt.Sprintf("%s/user/vehicles?%s", BaseURL, data.Encode()) + + req, err := request.New(http.MethodGet, uri, nil, map[string]string{ + "Accept": "application/hal+json", + "Authorization": "Bearer " + v.token.AccessToken, + "X-Introspect-Realm": v.realm, + }) + + var res struct { + Embedded struct { + Vehicles []Vehicle + } `json:"_embedded"` + } + if err == nil { + err = v.DoJSON(req, &res) + } + + return res.Embedded.Vehicles, err +} + +// Status is the /status response +type Status struct { + Battery struct { + Capacity int64 + Health struct { + Capacity int64 + Resistance int64 + } + } + Charging struct { + ChargingMode string + ChargingRate int64 + NextDelayedTime string + Plugged bool + RemainingTime string + Status string + } + Preconditionning struct { + AirConditioning struct { + UpdatedAt time.Time + Status string // Disabled + } + } + Energy []Energy +} + +// Energy is the /status partial energy response +type Energy struct { + UpdatedAt time.Time + Type string // Electric + Level int + Autonomy int + Charging struct { + Plugged bool + Status string // InProgress + RemainingTime Duration + ChargingRate int + ChargingMode string // "Slow" + NextDelayedTime Duration + } +} + +// Status implements the /vehicles//status response +func (v *API) Status(vid string) (Status, error) { + data := url.Values{ + "client_id": []string{v.id}, + } + + // BaseURL is the API base url + uri := fmt.Sprintf("%s/user/vehicles/%s/status?%s", BaseURL, vid, data.Encode()) + + req, err := request.New(http.MethodGet, uri, nil, map[string]string{ + "Accept": "application/hal+json", + "Authorization": "Bearer " + v.token.AccessToken, + "X-Introspect-Realm": v.realm, + }) + + var status Status + if err == nil { + err = v.DoJSON(req, &status) + } + + return status, err +} diff --git a/vehicle/psa/duration.go b/vehicle/psa/duration.go new file mode 100644 index 000000000..39203e5ba --- /dev/null +++ b/vehicle/psa/duration.go @@ -0,0 +1,36 @@ +package psa + +import ( + "encoding/json" + "errors" + "time" + + "github.com/dylanmei/iso8601" +) + +// Duration is a time.Duration that can be unmarshalled from JSON +type Duration struct { + time.Duration +} + +// UnmarshalJSON implements json.Unmarshaler +func (d *Duration) UnmarshalJSON(b []byte) error { + var v interface{} + if err := json.Unmarshal(b, &v); err != nil { + return err + } + + switch value := v.(type) { + case float64: + d.Duration = time.Duration(value) + return nil + + case string: + var err error + d.Duration, err = iso8601.ParseDuration(value) + return err + + default: + return errors.New("invalid duration") + } +} diff --git a/vehicle/psa/provider.go b/vehicle/psa/provider.go new file mode 100644 index 000000000..01cff73b6 --- /dev/null +++ b/vehicle/psa/provider.go @@ -0,0 +1,103 @@ +package psa + +import ( + "strings" + "time" + + "github.com/andig/evcc/api" + "github.com/andig/evcc/provider" +) + +// Provider is an api.Vehicle implementation for PSA cars +type Provider struct { + statusG func() (interface{}, error) +} + +// NewProvider creates a new vehicle +func NewProvider(api *API, vid string, cache time.Duration) *Provider { + impl := &Provider{ + statusG: provider.NewCached(func() (interface{}, error) { + return api.Status(vid) + }, cache).InterfaceGetter(), + } + return impl +} + +// SoC implements the api.Vehicle interface +func (v *Provider) SoC() (float64, error) { + res, err := v.statusG() + if res, ok := res.(Status); err == nil && ok { + for _, e := range res.Energy { + return float64(e.Level), nil + } + + err = api.ErrNotAvailable + } + + return 0, err +} + +// Range implements the api.VehicleRange interface +func (v *Provider) Range() (int64, error) { + res, err := v.statusG() + if res, ok := res.(Status); err == nil && ok { + for _, e := range res.Energy { + return int64(e.Autonomy), nil + } + + err = api.ErrNotAvailable + } + + return 0, err +} + +// FinishTime implements the api.VehicleFinishTimer interface +func (v *Provider) FinishTime() (time.Time, error) { + res, err := v.statusG() + if res, ok := res.(Status); err == nil && ok { + if len(res.Energy) == 1 { + e := res.Energy[0] + return e.UpdatedAt.Add(e.Charging.RemainingTime.Duration), nil + } + + err = api.ErrNotAvailable + } + + return time.Time{}, err +} + +// Status implements the api.VehicleStatus interface +func (v *Provider) Status() (api.ChargeStatus, error) { + res, err := v.statusG() + if res, ok := res.(Status); err == nil && ok { + if len(res.Energy) == 1 { + status := api.StatusA + + e := res.Energy[0] + if e.Charging.Plugged { + status = api.StatusB + + if strings.ToLower(e.Charging.Status) == "inprogress" { + status = api.StatusC + } + } + + return status, nil + } + + err = api.ErrNotAvailable + } + + return api.StatusNone, err +} + +// Climater implements the api.VehicleClimater interface +func (v *Provider) Climater() (active bool, outsideTemp float64, targetTemp float64, err error) { + res, err := v.statusG() + if res, ok := res.(Status); err == nil && ok { + active := strings.ToLower(res.Preconditionning.AirConditioning.Status) != "disabled" + return active, 20, 20, nil + } + + return active, outsideTemp, targetTemp, err +}