Add Peugeot/Opel/Citroen api (#692)
This commit is contained in:
parent
3a435ad68b
commit
d4f8d03cb9
9 changed files with 415 additions and 8 deletions
|
|
@ -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**
|
||||
|
|
|
|||
10
api/api.go
10
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
|
||||
}
|
||||
|
|
|
|||
1
go.mod
1
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
|
||||
|
|
|
|||
2
go.sum
2
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=
|
||||
|
|
|
|||
|
|
@ -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
93
vehicle/psa.go
Normal 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
167
vehicle/psa/api.go
Normal 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
36
vehicle/psa/duration.go
Normal 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
103
vehicle/psa/provider.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue