Add Peugeot/Opel/Citroen api (#692)
Some checks failed
Build / Build (push) Failing after 5m22s
Build / Build UI (push) Failing after 1m45s
Build / Publish :latest (push) Has been skipped
Build / Publish :release (push) Has been skipped
Build / Release (push) Has been skipped

This commit is contained in:
andig 2021-02-28 14:06:05 +01:00 • committed by GitHub
parent 3a435ad68b
commit d4f8d03cb9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 415 additions and 8 deletions

View file

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

View file

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

1
go.mod
View file

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

2
go.sum
View file

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

View file

@ -13,6 +13,9 @@ import (
// https://identity-userinfo.vwgroup.io/oidc/userinfo
// https://customer-profile.apps.emea.vwapps.io/v1/customers/<userId>/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",

93
vehicle/psa.go Normal file
View file

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

167
vehicle/psa/api.go Normal file
View file

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

36
vehicle/psa/duration.go Normal file
View file

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

103
vehicle/psa/provider.go Normal file
View file

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