Add BMW CarData api (#23972)
This commit is contained in:
parent
3139499b0f
commit
9dd4599fc2
17 changed files with 731 additions and 37 deletions
|
|
@ -209,9 +209,9 @@ type Tariff interface {
|
|||
|
||||
// AuthProvider is the ability to provide OAuth authentication through the ui
|
||||
type AuthProvider interface {
|
||||
Login(state string) string
|
||||
Login(state string) (string, error)
|
||||
Logout() error
|
||||
HandleCallback(responseValues url.Values) error
|
||||
HandleCallback(params url.Values) error
|
||||
Authenticated() bool
|
||||
DisplayName() string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ var ErrSponsorRequired = errors.New("sponsorship required, see https://docs.evcc
|
|||
// ErrMissingCredentials indicates that user/password are missing
|
||||
var ErrMissingCredentials = errors.New("missing user/password credentials")
|
||||
|
||||
// ErrLoginRequired indicates that retrieving tokens credentials waits for login
|
||||
var ErrLoginRequired = errors.New("login required")
|
||||
|
||||
// ErrMissingToken indicates that access/refresh tokens are missing
|
||||
var ErrMissingToken = errors.New("missing token credentials")
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/server/db/settings"
|
||||
|
|
@ -26,6 +27,30 @@ type OAuth struct {
|
|||
subject string
|
||||
cv string
|
||||
ctx context.Context
|
||||
|
||||
deviceFlow bool
|
||||
tokenRetriever func(string, *oauth2.Token) error
|
||||
tokenStorer func(*oauth2.Token) any
|
||||
}
|
||||
|
||||
type oauthOption func(*OAuth)
|
||||
|
||||
func WithOauthDeviceFlowOption() func(o *OAuth) {
|
||||
return func(o *OAuth) {
|
||||
o.deviceFlow = true
|
||||
}
|
||||
}
|
||||
|
||||
func WithTokenStorerOption(ts func(*oauth2.Token) any) func(o *OAuth) {
|
||||
return func(o *OAuth) {
|
||||
o.tokenStorer = ts
|
||||
}
|
||||
}
|
||||
|
||||
func WithTokenRetrieverOption(tr func(string, *oauth2.Token) error) func(o *OAuth) {
|
||||
return func(o *OAuth) {
|
||||
o.tokenRetriever = tr
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
|
|
@ -59,20 +84,23 @@ func NewOauthFromConfig(ctx context.Context, other map[string]any) (oauth2.Token
|
|||
return nil, err
|
||||
}
|
||||
|
||||
return NewOauth(ctx, &cc.Config, cc.Name)
|
||||
return NewOauth(ctx, cc.Name, &cc.Config)
|
||||
}
|
||||
|
||||
func NewOauth(ctx context.Context, oc *oauth2.Config, instanceName string) (oauth2.TokenSource, error) {
|
||||
log := util.NewLogger("oauth-generic")
|
||||
var (
|
||||
_ oauth.TokenRefresher = (*OAuth)(nil)
|
||||
_ api.AuthProvider = (*OAuth)(nil)
|
||||
)
|
||||
|
||||
if instanceName == "" {
|
||||
func NewOauth(ctx context.Context, name string, oc *oauth2.Config, opts ...oauthOption) (oauth2.TokenSource, error) {
|
||||
if name == "" {
|
||||
return nil, errors.New("instance name must not be empty")
|
||||
}
|
||||
|
||||
// hash oauth2 config
|
||||
h := sha256.Sum256(fmt.Append(nil, oc))
|
||||
hash := hex.EncodeToString(h[:])[:8]
|
||||
subject := instanceName + " (" + hash + ")"
|
||||
subject := name + " (" + hash + ")"
|
||||
|
||||
// reuse instance
|
||||
if instance := getInstance(subject); instance != nil {
|
||||
|
|
@ -83,21 +111,36 @@ func NewOauth(ctx context.Context, oc *oauth2.Config, instanceName string) (oaut
|
|||
o := &OAuth{
|
||||
subject: subject,
|
||||
oc: oc,
|
||||
log: log,
|
||||
log: util.NewLogger("oauth"),
|
||||
ctx: ctx,
|
||||
}
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(o)
|
||||
}
|
||||
|
||||
// load token from db
|
||||
var tok oauth2.Token
|
||||
var token oauth2.Token
|
||||
if settings.Exists(o.subject) {
|
||||
o.log.DEBUG.Printf("loading token for %s from database", o.subject)
|
||||
|
||||
if err := settings.Json(o.subject, &tok); err != nil {
|
||||
return nil, err
|
||||
if o.tokenRetriever != nil {
|
||||
plain, err := settings.String(o.subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := o.tokenRetriever(plain, &token); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if err := settings.Json(o.subject, &token); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
o.TokenSource = oauth.RefreshTokenSource(&tok, o)
|
||||
o.TokenSource = oauth.RefreshTokenSource(&token, o)
|
||||
|
||||
// add instance
|
||||
addInstance(o.subject, o)
|
||||
|
|
@ -108,69 +151,116 @@ func NewOauth(ctx context.Context, oc *oauth2.Config, instanceName string) (oaut
|
|||
return o, nil
|
||||
}
|
||||
|
||||
// RefreshToken implements oauth.RefreshTokenSource.
|
||||
// RefreshToken implements oauth.TokenRefresher.
|
||||
func (o *OAuth) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) {
|
||||
if token.RefreshToken == "" {
|
||||
return nil, api.ErrMissingToken
|
||||
}
|
||||
|
||||
// log token before refresh
|
||||
o.log.DEBUG.Printf("refreshing token for %s", o.subject)
|
||||
|
||||
// refresh token source
|
||||
token, err := o.oc.TokenSource(o.ctx, token).Token()
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "invalid_grant") {
|
||||
if settings.Exists(o.subject) {
|
||||
settings.Delete(o.subject)
|
||||
}
|
||||
if strings.Contains(err.Error(), "invalid_grant") && settings.Exists(o.subject) {
|
||||
settings.Delete(o.subject)
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = settings.SetJson(o.subject, token)
|
||||
|
||||
return token, err
|
||||
}
|
||||
|
||||
// updateToken must only be called when lock is held
|
||||
func (o *OAuth) updateToken(token *oauth2.Token) {
|
||||
var store any = token
|
||||
|
||||
// tokenStorer allows persisting the token together with it's extra properties
|
||||
if o.tokenStorer != nil {
|
||||
store = o.tokenStorer(token)
|
||||
}
|
||||
|
||||
if err := settings.SetJson(o.subject, store); err != nil {
|
||||
o.log.ERROR.Printf("error saving token: %v", err)
|
||||
}
|
||||
|
||||
o.TokenSource = oauth.RefreshTokenSource(token, o)
|
||||
}
|
||||
|
||||
// HandleCallback implements api.AuthProvider.
|
||||
func (o *OAuth) HandleCallback(responseValues url.Values) error {
|
||||
code := responseValues.Get("code")
|
||||
func (o *OAuth) HandleCallback(params url.Values) error {
|
||||
code := params.Get("code")
|
||||
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
|
||||
token, err := o.oc.Exchange(o.ctx, code, oauth2.VerifierOption(o.cv))
|
||||
if err != nil {
|
||||
o.log.ERROR.Printf("error during oauth exchange: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := settings.SetJson(o.subject, token); err != nil {
|
||||
o.log.ERROR.Printf("error saving token: %s", err)
|
||||
}
|
||||
o.updateToken(token)
|
||||
|
||||
o.TokenSource = oauth.RefreshTokenSource(token, o)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Login implements api.AuthProvider.
|
||||
func (o *OAuth) Login(state string) string {
|
||||
func (o *OAuth) Login(state string) (string, error) {
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
|
||||
o.cv = oauth2.GenerateVerifier()
|
||||
return o.oc.AuthCodeURL(state, oauth2.S256ChallengeOption(o.cv))
|
||||
|
||||
if o.deviceFlow {
|
||||
ctx := context.Background()
|
||||
|
||||
da, err := o.oc.DeviceAuth(ctx, oauth2.S256ChallengeOption(o.cv))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Minute)
|
||||
defer cancel()
|
||||
|
||||
token, err := o.oc.DeviceAccessToken(ctx, da, oauth2.VerifierOption(o.cv))
|
||||
if err != nil {
|
||||
o.log.ERROR.Printf("error retrieving token: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
|
||||
o.updateToken(token)
|
||||
}()
|
||||
|
||||
return da.VerificationURIComplete, nil
|
||||
}
|
||||
|
||||
if o.oc.Endpoint.AuthURL == "" {
|
||||
return "", errors.New("missing auth url")
|
||||
}
|
||||
|
||||
return o.oc.AuthCodeURL(state, oauth2.S256ChallengeOption(o.cv)), nil
|
||||
}
|
||||
|
||||
// Logout implements api.AuthProvider.
|
||||
func (o *OAuth) Logout() error {
|
||||
o.log.INFO.Printf("removing %s from database", o.subject)
|
||||
o.log.DEBUG.Printf("removing %s from database", o.subject)
|
||||
|
||||
if settings.Exists(o.subject) {
|
||||
settings.Delete(o.subject)
|
||||
if err := settings.Delete(o.subject); err != nil {
|
||||
o.log.ERROR.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
|
||||
o.TokenSource = oauth.RefreshTokenSource(nil, o)
|
||||
return nil
|
||||
}
|
||||
|
|
@ -182,6 +272,6 @@ func (o *OAuth) DisplayName() string {
|
|||
|
||||
// Authenticated implements api.AuthProvider.
|
||||
func (o *OAuth) Authenticated() bool {
|
||||
token, err := o.TokenSource.Token()
|
||||
token, err := o.Token()
|
||||
return err == nil && token.Valid()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ func (a *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||
provider, ok := a.providers[id]
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprintf(w, "invalid id")
|
||||
fmt.Fprintln(w, "invalid id")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -85,11 +85,18 @@ func (a *Handler) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||
delete(a.states, encryptedState)
|
||||
})
|
||||
|
||||
uri, err := provider.Login(encryptedState)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprintf(w, "error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// return authorization URL
|
||||
res := struct {
|
||||
LoginUri string `json:"loginUri"`
|
||||
}{
|
||||
LoginUri: provider.Login(encryptedState),
|
||||
LoginUri: uri,
|
||||
}
|
||||
|
||||
if err := json.NewEncoder(w).Encode(res); err != nil {
|
||||
|
|
@ -109,7 +116,7 @@ func (a *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|||
provider, ok := a.providers[id]
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprintf(w, "invalid id")
|
||||
fmt.Fprintln(w, "invalid id")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -161,9 +168,9 @@ func (a *Handler) handleCallback(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// Handle the callback
|
||||
if err := provider.HandleCallback(r.URL.Query()); err != nil {
|
||||
a.log.ERROR.Printf("callback handling for provider %s failed: %v", id, err)
|
||||
a.log.ERROR.Printf("callback for provider %s failed: %v", id, err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprintf(w, "callback handling failed")
|
||||
fmt.Fprintln(w, "callback failed")
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
26
templates/definition/vehicle/cardata.yaml
Normal file
26
templates/definition/vehicle/cardata.yaml
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
template: cardata
|
||||
products:
|
||||
- brand: BMW
|
||||
description:
|
||||
generic: CarData (EU Data Act)
|
||||
- brand: Mini
|
||||
description:
|
||||
generic: CarData (EU Data Act)
|
||||
requirements:
|
||||
description:
|
||||
de: |
|
||||
Benötigt CarData Einrichtung im BMW/Mini portal.
|
||||
en: |
|
||||
Requires CarData activation in BMW/Mini portal.
|
||||
params:
|
||||
- preset: vehicle-common
|
||||
- name: vin
|
||||
example: WBMW...
|
||||
- name: clientid
|
||||
- preset: vehicle-features
|
||||
render: |
|
||||
type: cardata
|
||||
vin: {{ .vin }}
|
||||
clientid: {{ .clientid }}
|
||||
{{ include "vehicle-common" . }}
|
||||
{{ include "vehicle-features" . }}
|
||||
|
|
@ -5,7 +5,7 @@ import (
|
|||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/vehicle/bmw"
|
||||
bmw "github.com/evcc-io/evcc/vehicle/bmw/connected"
|
||||
)
|
||||
|
||||
// BMW is an api.Vehicle implementation for BMW and Mini cars
|
||||
|
|
|
|||
150
vehicle/bmw/cardata/api.go
Normal file
150
vehicle/bmw/cardata/api.go
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
package cardata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/request"
|
||||
"github.com/evcc-io/evcc/util/transport"
|
||||
"github.com/samber/lo"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
const ApiURL = "https://api-cardata.bmwgroup.com"
|
||||
|
||||
var Config = oauth2.Config{
|
||||
Scopes: []string{"authenticate_user", "openid", "cardata:streaming:read", "cardata:api:read"},
|
||||
Endpoint: oauth2.Endpoint{
|
||||
DeviceAuthURL: "https://customer.bmwgroup.com/gcdm/oauth/device/code",
|
||||
TokenURL: "https://customer.bmwgroup.com/gcdm/oauth/token",
|
||||
},
|
||||
}
|
||||
|
||||
// requiredKeys are the necessary data dictionary entities according to
|
||||
// https://mybmwweb-utilities.api.bmw/de-de/utilities/bmw/api/cd/catalogue/file
|
||||
var requiredKeys = []string{
|
||||
"vehicle.body.chargingPort.status",
|
||||
"vehicle.cabin.hvac.preconditioning.status.comfortState",
|
||||
"vehicle.drivetrain.batteryManagement.header",
|
||||
"vehicle.drivetrain.electricEngine.charging.hvStatus",
|
||||
"vehicle.drivetrain.electricEngine.charging.level",
|
||||
"vehicle.drivetrain.electricEngine.charging.timeToFullyCharged",
|
||||
"vehicle.drivetrain.electricEngine.kombiRemainingElectricRange",
|
||||
"vehicle.powertrain.electric.battery.stateOfCharge.target",
|
||||
"vehicle.vehicle.travelledDistance",
|
||||
}
|
||||
|
||||
type API struct {
|
||||
*request.Helper
|
||||
}
|
||||
|
||||
func NewAPI(log *util.Logger, ts oauth2.TokenSource) *API {
|
||||
v := &API{
|
||||
Helper: request.NewHelper(log),
|
||||
}
|
||||
|
||||
v.Transport = &oauth2.Transport{
|
||||
Source: ts,
|
||||
Base: &transport.Decorator{
|
||||
Decorator: transport.DecorateHeaders(map[string]string{
|
||||
"x-version": "v1",
|
||||
}),
|
||||
Base: v.Transport,
|
||||
},
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func (v *API) Vehicles() ([]string, error) {
|
||||
var res []VehicleMapping
|
||||
err := v.GetJSON(ApiURL+"/customers/vehicles/mappings", &res)
|
||||
|
||||
return lo.Map(
|
||||
lo.Filter(res, func(v VehicleMapping, _ int) bool {
|
||||
return v.MappingType == "PRIMARY"
|
||||
}), func(v VehicleMapping, _ int) string {
|
||||
return v.Vin
|
||||
},
|
||||
), err
|
||||
}
|
||||
|
||||
func (v *API) GetContainers() ([]Container, error) {
|
||||
var res struct {
|
||||
Containers []Container
|
||||
}
|
||||
if err := v.GetJSON(ApiURL+"/customers/containers", &res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return lo.Filter(res.Containers, func(c Container, _ int) bool {
|
||||
return c.Name == "evcc.io"
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (v *API) CreateContainer() error {
|
||||
data := CreateContainer{
|
||||
Name: "evcc.io",
|
||||
Purpose: "evcc.io",
|
||||
TechnicalDescriptors: requiredKeys,
|
||||
}
|
||||
|
||||
var res any
|
||||
req, _ := request.New(http.MethodPost, ApiURL+"/customers/containers", request.MarshalJSON(data))
|
||||
return v.DoJSON(req, &res)
|
||||
}
|
||||
|
||||
// func (v *API) DeleteContainer() error {
|
||||
// if *deleteContainer && len(containers) == 1 {
|
||||
// req, _ := request.New(http.MethodDelete, apiUrl+"/customers/containers/"+containers[0].ContainerId, nil)
|
||||
|
||||
// var res any
|
||||
// if err := client.DoJSON(req, &res); err != nil {
|
||||
// return err
|
||||
// }
|
||||
|
||||
// containers = nil
|
||||
// }
|
||||
// }
|
||||
|
||||
func (v *API) EnsureContainer() (string, error) {
|
||||
containers, err := v.GetContainers()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(containers) > 0 {
|
||||
return containers[0].ContainerId, nil
|
||||
}
|
||||
|
||||
data := CreateContainer{
|
||||
Name: "evcc.io",
|
||||
Purpose: "evcc.io",
|
||||
TechnicalDescriptors: []string{
|
||||
// https://mybmwweb-utilities.api.bmw/de-de/utilities/bmw/api/cd/catalogue/file
|
||||
"vehicle.body.chargingPort.status",
|
||||
"vehicle.cabin.hvac.preconditioning.status.comfortState",
|
||||
"vehicle.drivetrain.batteryManagement.header",
|
||||
"vehicle.drivetrain.electricEngine.charging.hvStatus",
|
||||
"vehicle.drivetrain.electricEngine.charging.level",
|
||||
"vehicle.drivetrain.electricEngine.charging.timeToFullyCharged",
|
||||
"vehicle.drivetrain.electricEngine.kombiRemainingElectricRange",
|
||||
"vehicle.powertrain.electric.battery.stateOfCharge.target",
|
||||
"vehicle.vehicle.travelledDistance",
|
||||
},
|
||||
}
|
||||
|
||||
var res Container
|
||||
|
||||
req, _ := request.New(http.MethodPost, ApiURL+"/customers/containers", request.MarshalJSON(data))
|
||||
err = v.DoJSON(req, &res)
|
||||
|
||||
return res.ContainerId, err
|
||||
}
|
||||
|
||||
func (v *API) GetTelematics(vin, container string) (TelematicData, error) {
|
||||
var res TelematicData
|
||||
uri := fmt.Sprintf(ApiURL+"/customers/vehicles/%s/telematicData?containerId=%s", vin, container)
|
||||
err := v.GetJSON(uri, &res)
|
||||
return res, err
|
||||
}
|
||||
246
vehicle/bmw/cardata/provider.go
Normal file
246
vehicle/bmw/cardata/provider.go
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
package cardata
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/spf13/cast"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
const StreamingURL = "tls://customer.streaming-cardata.bmwgroup.com:9000"
|
||||
|
||||
// Provider implements the vehicle api
|
||||
type Provider struct {
|
||||
mu sync.Mutex
|
||||
log *util.Logger
|
||||
api *API
|
||||
ts oauth2.TokenSource
|
||||
|
||||
vin string
|
||||
|
||||
initial map[string]TelematicDataPoint
|
||||
streaming map[string]StreamingData
|
||||
}
|
||||
|
||||
// NewProvider creates a vehicle api provider
|
||||
func NewProvider(log *util.Logger, api *API, ts oauth2.TokenSource, vin string) *Provider {
|
||||
v := &Provider{
|
||||
log: log,
|
||||
api: api,
|
||||
ts: ts,
|
||||
vin: vin,
|
||||
streaming: make(map[string]StreamingData),
|
||||
}
|
||||
|
||||
go func() {
|
||||
bo := backoff.NewExponentialBackOff(backoff.WithMaxInterval(time.Minute))
|
||||
|
||||
for {
|
||||
token, err := ts.Token()
|
||||
if err != nil {
|
||||
if !tokenError(err) {
|
||||
v.log.ERROR.Println(err)
|
||||
}
|
||||
|
||||
time.Sleep(bo.NextBackOff())
|
||||
}
|
||||
|
||||
bo.Reset()
|
||||
|
||||
if err := v.runMqtt(vin, token); err != nil {
|
||||
v.log.ERROR.Println(err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
func (v *Provider) runMqtt(vin string, token *oauth2.Token) error {
|
||||
gcid := TokenExtra(token, "gcid")
|
||||
idToken := TokenExtra(token, "id_token")
|
||||
|
||||
paho := mqtt.NewClient(
|
||||
mqtt.NewClientOptions().
|
||||
AddBroker(StreamingURL).
|
||||
SetAutoReconnect(true).
|
||||
SetUsername(gcid).
|
||||
SetPassword(idToken))
|
||||
|
||||
timeout := 30 * time.Second
|
||||
if t := paho.Connect(); !t.WaitTimeout(timeout) {
|
||||
return errors.New("connect timeout")
|
||||
} else if err := t.Error(); err != nil {
|
||||
return fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
defer paho.Disconnect(0)
|
||||
|
||||
topic := fmt.Sprintf("%s/%s", gcid, vin)
|
||||
|
||||
if t := paho.Subscribe(topic, 0, v.handler); !t.WaitTimeout(timeout) {
|
||||
return errors.New("subcribe timeout")
|
||||
} else if err := t.Error(); err != nil {
|
||||
return fmt.Errorf("subscribe: %w", err)
|
||||
}
|
||||
|
||||
time.Sleep(time.Until(token.Expiry))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *Provider) handler(c mqtt.Client, m mqtt.Message) {
|
||||
var res StreamingMessage
|
||||
if err := json.Unmarshal(m.Payload(), &res); err != nil {
|
||||
v.log.ERROR.Println(m.Topic(), string(m.Payload()), err)
|
||||
return
|
||||
}
|
||||
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
maps.Copy(v.streaming, res.Data)
|
||||
}
|
||||
|
||||
func (v *Provider) any(key string) (any, error) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
if a, ok := v.streaming[key]; ok {
|
||||
return a, nil
|
||||
}
|
||||
|
||||
if v.initial == nil {
|
||||
// don't try as long as there's no token
|
||||
if _, err := v.ts.Token(); err != nil {
|
||||
return nil, api.ErrNotAvailable
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if v.initial == nil {
|
||||
v.initial = make(map[string]TelematicDataPoint)
|
||||
}
|
||||
}()
|
||||
|
||||
container, err := v.api.EnsureContainer()
|
||||
if err != nil {
|
||||
v.log.ERROR.Printf("get container: %v", err)
|
||||
return nil, api.ErrNotAvailable
|
||||
}
|
||||
|
||||
if res, err := v.api.GetTelematics(v.vin, container); err == nil {
|
||||
v.initial = res.TelematicData
|
||||
} else {
|
||||
v.log.ERROR.Printf("get telematics: %v", err)
|
||||
return nil, api.ErrNotAvailable
|
||||
}
|
||||
}
|
||||
|
||||
if el, ok := v.initial[key]; ok {
|
||||
return el.Value, nil
|
||||
}
|
||||
|
||||
return nil, api.ErrNotAvailable
|
||||
}
|
||||
|
||||
func (v *Provider) String(key string) (string, error) {
|
||||
res, err := v.any(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return cast.ToStringE(res)
|
||||
}
|
||||
|
||||
func (v *Provider) Int(key string) (int64, error) {
|
||||
res, err := v.any(key)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return cast.ToInt64E(res)
|
||||
}
|
||||
|
||||
func (v *Provider) Float(key string) (float64, error) {
|
||||
res, err := v.any(key)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return cast.ToFloat64E(res)
|
||||
}
|
||||
|
||||
var _ api.Battery = (*Provider)(nil)
|
||||
|
||||
// Soc implements the api.Vehicle interface
|
||||
func (v *Provider) Soc() (float64, error) {
|
||||
return v.Float("vehicle.drivetrain.electricEngine.charging.level")
|
||||
}
|
||||
|
||||
var _ api.ChargeState = (*Provider)(nil)
|
||||
|
||||
// Status implements the api.ChargeState interface
|
||||
func (v *Provider) Status() (api.ChargeStatus, error) {
|
||||
port, err := v.String("vehicle.body.chargingPort.status")
|
||||
if err != nil {
|
||||
return api.StatusNone, err
|
||||
}
|
||||
|
||||
status := api.StatusA // disconnected
|
||||
if port == "CONNECTED" {
|
||||
status = api.StatusB
|
||||
}
|
||||
|
||||
hv, err := v.String("vehicle.drivetrain.electricEngine.charging.hvStatus")
|
||||
if hv == "CHARGING" {
|
||||
status = api.StatusC
|
||||
}
|
||||
|
||||
return status, err
|
||||
}
|
||||
|
||||
var _ api.VehicleFinishTimer = (*Provider)(nil)
|
||||
|
||||
// FinishTime implements the api.VehicleFinishTimer interface
|
||||
func (v *Provider) FinishTime() (time.Time, error) {
|
||||
res, err := v.Int("vehicle.drivetrain.electricEngine.charging.timeToFullyCharged")
|
||||
return time.Now().Add(time.Duration(res) * time.Minute), err
|
||||
}
|
||||
|
||||
var _ api.VehicleRange = (*Provider)(nil)
|
||||
|
||||
// Range implements the api.VehicleRange interface
|
||||
func (v *Provider) Range() (int64, error) {
|
||||
return v.Int("vehicle.drivetrain.electricEngine.kombiRemainingElectricRange")
|
||||
}
|
||||
|
||||
var _ api.VehicleOdometer = (*Provider)(nil)
|
||||
|
||||
// Odometer implements the api.VehicleOdometer interface
|
||||
func (v *Provider) Odometer() (float64, error) {
|
||||
return v.Float("vehicle.vehicle.travelledDistance")
|
||||
}
|
||||
|
||||
var _ api.SocLimiter = (*Provider)(nil)
|
||||
|
||||
// GetLimitSoc implements the api.SocLimiter interface
|
||||
func (v *Provider) GetLimitSoc() (int64, error) {
|
||||
return v.Int("vehicle.powertrain.electric.battery.stateOfCharge.target")
|
||||
}
|
||||
|
||||
var _ api.VehicleClimater = (*Provider)(nil)
|
||||
|
||||
// Climater implements the api.VehicleClimater interface
|
||||
func (v *Provider) Climater() (bool, error) {
|
||||
res, err := v.String("vehicle.cabin.hvac.preconditioning.status.comfortState")
|
||||
return slices.Contains([]string{"COMFORT_HEATING", "COMFORT_COOLING", "COMFORT_VENTILATION", "DEFROST"}, res), err
|
||||
}
|
||||
33
vehicle/bmw/cardata/token.go
Normal file
33
vehicle/bmw/cardata/token.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package cardata
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type Token struct {
|
||||
*oauth2.Token
|
||||
IdToken string `json:"id_token"`
|
||||
Gcid string `json:"gcid"`
|
||||
}
|
||||
|
||||
func (t *Token) TokenEx() *oauth2.Token {
|
||||
return t.Token.WithExtra(map[string]any{
|
||||
"id_token": t.IdToken,
|
||||
"gcid": t.Gcid,
|
||||
})
|
||||
}
|
||||
|
||||
// TokenExtra returns extra string properties of the oauth2.Token
|
||||
func TokenExtra(t *oauth2.Token, key string) string {
|
||||
if v := t.Extra(key); v != nil {
|
||||
return v.(string)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func tokenError(err error) bool {
|
||||
return errors.Is(err, api.ErrLoginRequired) || errors.Is(err, api.ErrMissingToken)
|
||||
}
|
||||
46
vehicle/bmw/cardata/types.go
Normal file
46
vehicle/bmw/cardata/types.go
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
package cardata
|
||||
|
||||
import "time"
|
||||
|
||||
type VehicleMapping struct {
|
||||
Vin string
|
||||
MappedSince time.Time
|
||||
MappingType string
|
||||
}
|
||||
|
||||
type Container struct {
|
||||
Name string `json:"name"`
|
||||
Purpose string `json:"purpose"`
|
||||
ContainerId string `json:"containerId"`
|
||||
Created time.Time `json:"created"`
|
||||
}
|
||||
|
||||
type CreateContainer struct {
|
||||
Name string `json:"name"`
|
||||
Purpose string `json:"purpose"`
|
||||
TechnicalDescriptors []string `json:"technicalDescriptors"`
|
||||
}
|
||||
|
||||
type TelematicDataPoint struct {
|
||||
Timestamp time.Time
|
||||
Unit string
|
||||
Value string
|
||||
}
|
||||
|
||||
type TelematicData struct {
|
||||
TelematicData map[string]TelematicDataPoint
|
||||
}
|
||||
|
||||
type StreamingMessage struct {
|
||||
Vin string
|
||||
EntityId string
|
||||
Topic string
|
||||
TimeStamp time.Time
|
||||
Data map[string]StreamingData
|
||||
}
|
||||
|
||||
type StreamingData struct {
|
||||
TimeStamp time.Time
|
||||
Value any
|
||||
Unit string
|
||||
}
|
||||
93
vehicle/cardata.go
Normal file
93
vehicle/cardata.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
package vehicle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/plugin/auth"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/request"
|
||||
"github.com/evcc-io/evcc/vehicle/bmw/cardata"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// Cardata is an api.Vehicle implementation for BMW and Mini cars
|
||||
type Cardata struct {
|
||||
*embed
|
||||
*cardata.Provider // provides the api implementations
|
||||
}
|
||||
|
||||
func init() {
|
||||
registry.AddCtx("cardata", NewCardataFromConfig)
|
||||
}
|
||||
|
||||
// NewCardataFromConfig creates a new BMW/Mini CarData vehicle
|
||||
func NewCardataFromConfig(ctx context.Context, other map[string]interface{}) (api.Vehicle, error) {
|
||||
cc := struct {
|
||||
embed `mapstructure:",squash"`
|
||||
ClientID, VIN string
|
||||
Cache time.Duration
|
||||
}{
|
||||
Cache: 30 * time.Minute, // 50 requests per day
|
||||
}
|
||||
|
||||
if err := util.DecodeOther(other, &cc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cc.ClientID == "" {
|
||||
return nil, api.ErrMissingCredentials
|
||||
}
|
||||
|
||||
v := &Cardata{
|
||||
embed: &cc.embed,
|
||||
}
|
||||
|
||||
oc := cardata.Config
|
||||
oc.ClientID = cc.ClientID
|
||||
|
||||
log := util.NewLogger("cardata").Redact(cc.ClientID)
|
||||
|
||||
ts, err := auth.NewOauth(ctx, "cardata", &oc,
|
||||
auth.WithOauthDeviceFlowOption(),
|
||||
auth.WithTokenRetrieverOption(func(data string, res *oauth2.Token) error {
|
||||
var token cardata.Token
|
||||
if err := json.Unmarshal([]byte(data), &token); err != nil {
|
||||
return err
|
||||
}
|
||||
*res = *token.TokenEx()
|
||||
return nil
|
||||
}),
|
||||
auth.WithTokenStorerOption(func(token *oauth2.Token) any {
|
||||
return cardata.Token{
|
||||
Token: token,
|
||||
IdToken: cardata.TokenExtra(token, "id_token"),
|
||||
Gcid: cardata.TokenExtra(token, "gcid"),
|
||||
}
|
||||
}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
api := cardata.NewAPI(log, ts)
|
||||
|
||||
is429 := func(err error) bool {
|
||||
se := new(request.StatusError)
|
||||
return errors.As(err, &se) && se.StatusCode() == http.StatusTooManyRequests
|
||||
}
|
||||
|
||||
vehicle, err := ensureVehicle(
|
||||
cc.VIN, api.Vehicles,
|
||||
)
|
||||
if err != nil && (cc.VIN == "" || !is429(err)) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
v.Provider = cardata.NewProvider(log, api, ts, vehicle)
|
||||
|
||||
return v, nil
|
||||
}
|
||||
|
|
@ -45,7 +45,7 @@ func NewVolvoConnectedFromConfig(ctx context.Context, other map[string]interface
|
|||
oc := connected.Oauth2Config(cc.Credentials.ID, cc.Credentials.Secret, cc.RedirectUri)
|
||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, request.NewClient(log))
|
||||
|
||||
ts, err := auth.NewOauth(ctx, oc, cc.embed.GetTitle())
|
||||
ts, err := auth.NewOauth(ctx, cc.embed.GetTitle(), oc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue