Replace VW We with VW Id api (#367)

This commit is contained in:
andig 2020-10-03 16:30:06 +02:00 • committed by GitHub
parent 2d7c6d2b44
commit 95cc086226
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 462 additions and 323 deletions

View file

@ -129,3 +129,20 @@ func (c *Cached) TimeGetter() func() (time.Time, error) {
return c.val.(time.Time), c.err
}
}
// InterfaceGetter gets interface value
func (c *Cached) InterfaceGetter() func() (interface{}, error) {
g, ok := c.getter.(func() (interface{}, error))
if !ok {
c.log.FATAL.Fatalf("invalid type: %T", c.getter)
}
return func() (interface{}, error) {
if c.clock.Since(c.updated) > c.cache {
c.val, c.err = g()
c.updated = c.clock.Now()
}
return c.val, c.err
}
}

View file

@ -17,6 +17,30 @@ var (
JSONEncoding = map[string]string{"Content-Type": "application/json"}
)
// StatusError indicates unsuccessful http response
type StatusError struct {
resp *http.Response
}
// NewStatusError create new StatusError for given response
func NewStatusError(resp *http.Response) StatusError {
return StatusError{resp: resp}
}
func (e StatusError) Error() string {
return fmt.Sprintf("unexpected status: %d", e.resp.StatusCode)
}
// Response returns the respose with the unexpected error
func (e StatusError) Response() *http.Response {
return e.resp
}
// StatusCode returns the respose's status code
func (e StatusError) StatusCode() int {
return e.resp.StatusCode
}
// ReadBody reads HTTP response and returns error on response codes other than HTTP 2xx
func ReadBody(resp *http.Response) ([]byte, error) {
defer resp.Body.Close()
@ -30,7 +54,7 @@ func ReadBody(resp *http.Response) ([]byte, error) {
resp.Body = ioutil.NopCloser(bytes.NewBuffer(b))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return b, fmt.Errorf("unexpected response %d: %s", resp.StatusCode, string(b))
return b, StatusError{resp: resp}
}
return b, nil

View file

@ -4,6 +4,7 @@ import (
"fmt"
"log"
"net/http"
"net/http/httputil"
"strings"
"time"
@ -41,6 +42,7 @@ func (r *Helper) LastResponse() *http.Response {
type helperTransport struct {
log *log.Logger
detailed bool
lastResponse func(*http.Response)
roundTripper http.RoundTripper
}
@ -52,9 +54,23 @@ func (r *helperTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if r.log != nil {
msg := fmt.Sprintf("%s %s", req.Method, req.URL)
if resp != nil {
msg += "\n" + resp.Status
if body, _ := ReadBody(resp); len(body) > 0 {
msg += "\n" + strings.TrimSpace(string(body))
if r.detailed {
if body, err := httputil.DumpResponse(resp, true); err == nil {
msg += "\n" + strings.TrimSpace(string(body))
}
} else {
msg += "\n" + resp.Status
if body, _ := ReadBody(resp); len(body) > 0 {
const max = 2048
str := string(body)
if len(str) >= max {
str = str[:max]
}
msg += "\n" + strings.TrimSpace(str)
}
}
}
r.log.Println(msg)

View file

@ -2,7 +2,6 @@ package vehicle
import (
"errors"
"fmt"
"net/http"
"net/http/cookiejar"
"net/url"
@ -13,71 +12,26 @@ import (
"github.com/andig/evcc/provider"
"github.com/andig/evcc/util"
"github.com/andig/evcc/util/request"
"github.com/andig/evcc/vehicle/vwidentity"
"github.com/andig/evcc/vehicle/vw"
"golang.org/x/net/publicsuffix"
)
const (
vwAPI = "https://msg.volkswagen.de/fs-car"
)
// OIDCResponse is the well-known OIDC provider response
// https://{oauth-provider-hostname}/.well-known/openid-configuration
type OIDCResponse struct {
Issuer string `json:"issuer"`
AuthURL string `json:"authorization_endpoint"`
TokenURL string `json:"token_endpoint"`
JWKSURL string `json:"jwks_uri"`
UserInfoURL string `json:"userinfo_endpoint"`
Algorithms []string `json:"id_token_signing_alg_values_supported"`
}
type audiTokenResponse struct {
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
IDToken string `json:"id_token"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
type audiVehiclesResponse struct {
UserVehicles struct {
Vehicle []string
}
}
type audiChargerResponse struct {
Charger struct {
Status struct {
BatteryStatusData struct {
StateOfCharge struct {
Content int
Timestamp string
}
RemainingChargingTime struct {
Content int
Timestamp string
}
}
}
}
}
// Audi is an api.Vehicle implementation for Audi cars
type Audi struct {
*embed
*request.Helper
user, password, vin string
brand, country string
tokens audiTokenResponse
chargeStateG func() (float64, error)
finishTimeG func() (time.Time, error)
user, password string
tokens vw.Tokens
api *vw.API
apiG func() (interface{}, error)
}
func init() {
registry.Add("audi", NewAudiFromConfig)
}
const audiClientID = "77869e21-e30a-4a92-b016-48ab7d3db1d8"
// NewAudiFromConfig creates a new vehicle
func NewAudiFromConfig(other map[string]interface{}) (api.Vehicle, error) {
cc := struct {
@ -97,13 +51,10 @@ func NewAudiFromConfig(other map[string]interface{}) (api.Vehicle, error) {
Helper: request.NewHelper(log),
user: cc.User,
password: cc.Password,
vin: strings.ToUpper(cc.VIN),
brand: "Audi",
country: "DE",
}
v.chargeStateG = provider.NewCached(v.chargeState, cc.Cache).FloatGetter()
v.finishTimeG = provider.NewCached(v.finishTime, cc.Cache).TimeGetter()
v.api = vw.NewAPI(v.Helper, &v.tokens, v.authFlow, v.refreshHeaders, strings.ToUpper(cc.VIN), "Audi", "DE")
v.apiG = provider.NewCached(v.apiCall, cc.Cache).InterfaceGetter()
var err error
jar, err := cookiejar.New(&cookiejar.Options{
@ -121,9 +72,9 @@ func NewAudiFromConfig(other map[string]interface{}) (api.Vehicle, error) {
}
if err == nil && cc.VIN == "" {
v.vin, err = findVehicle(v.vehicles())
v.api.VIN, err = findVehicle(v.api.Vehicles())
if err == nil {
log.DEBUG.Printf("found vehicle: %v", v.vin)
log.DEBUG.Printf("found vehicle: %v", v.api.VIN)
}
}
@ -131,18 +82,32 @@ func NewAudiFromConfig(other map[string]interface{}) (api.Vehicle, error) {
}
func (v *Audi) authFlow() error {
var uri, body string
var err error
var uri string
var req *http.Request
var resp *http.Response
var tokens vw.Tokens
uri = "https://identity.vwgroup.io/oidc/v1/authorize?" +
"response_type=code&client_id=09b6cbec-cd19-4589-82fd-363dfa8c24da%40apps_vw-dilab_com&" +
"redirect_uri=myaudi%3A%2F%2F%2F&scope=address%20profile%20badge%20birthdate%20birthplace%20nationalIdentifier%20nationality%20profession%20email%20vin%20phone%20nickname%20name%20picture%20mbb%20gallery%20openid&" +
"state=7f8260b5-682f-4db8-b171-50a5189a1c08&nonce=583b9af2-7799-4c72-9cb0-e6c0f42b87b3&prompt=login&ui_locales=de-DE"
const clientID = "09b6cbec-cd19-4589-82fd-363dfa8c24da@apps_vw-dilab_com"
const redirectURI = "myaudi:///"
identity := &vwidentity.Identity{Client: v.Client}
resp, err := identity.Login(uri, v.user, v.password)
query := url.Values(map[string][]string{
"response_type": {"code"},
"client_id": {clientID},
"redirect_uri": {redirectURI},
"scope": {"address profile badge birthdate birthplace nationalIdentifier nationality profession email vin phone nickname name picture mbb gallery openid"},
"state": {"7f8260b5-682f-4db8-b171-50a5189a1c08"},
"nonce": {"7f8260b5-682f-4db8-b171-50a5189a1c08"},
"prompt": {"login"},
"ui_locales": {"de-DE"},
})
uri = "https://identity.vwgroup.io/oidc/v1/authorize?" + query.Encode()
if err == nil {
identity := &vw.Identity{Client: v.Client}
resp, err = identity.Login(uri, v.user, v.password)
}
var tokens audiTokenResponse
if err == nil {
var code string
if location, err := url.Parse(resp.Header.Get("Location")); err == nil {
@ -150,134 +115,79 @@ func (v *Audi) authFlow() error {
}
data := url.Values(map[string][]string{
"client_id": {"09b6cbec-cd19-4589-82fd-363dfa8c24da@apps_vw-dilab_com"},
"client_id": {clientID},
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {"myaudi:///"},
"redirect_uri": {redirectURI},
"response_type": {"token id_token"},
})
uri = "https://app-api.my.audi.com/myaudiappidk/v1/token"
req, err = request.New(http.MethodPost, uri, strings.NewReader(data.Encode()), request.URLEncoding)
if err == nil {
err = v.DoJSON(req, &tokens)
if err = v.DoJSON(req, &tokens); err == nil && tokens.IDToken == "" {
err = errors.New("missing id token (1)")
}
}
}
if err == nil {
body = fmt.Sprintf("grant_type=%s&token=%s&scope=%s", "id_token", tokens.IDToken, "sc2:fal")
headers := map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
"X-App-Version": "3.14.0",
"X-App-Name": "myAudi",
"X-Client-Id": "77869e21-e30a-4a92-b016-48ab7d3db1d8",
}
data := url.Values(map[string][]string{
"grant_type": {"id_token"},
"scope": {"sc2:fal"},
"token": {tokens.IDToken},
})
req, err = request.New(http.MethodPost, vwidentity.OauthTokenURI, strings.NewReader(body), headers)
req, err = request.New(http.MethodPost, vw.OauthTokenURI, strings.NewReader(data.Encode()), map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
"X-Client-Id": audiClientID,
})
if err == nil {
err = v.DoJSON(req, &tokens)
v.tokens = tokens
if err = v.DoJSON(req, &v.tokens); err == nil && tokens.IDToken == "" {
err = errors.New("missing id token (2)")
}
}
}
return err
}
func (v *Audi) refreshToken() error {
if v.tokens.RefreshToken == "" {
return errors.New("missing refresh token")
}
body := fmt.Sprintf("grant_type=%s&refresh_token=%s&scope=%s", "refresh_token", v.tokens.RefreshToken, "sc2:fal")
headers := map[string]string{
func (v *Audi) refreshHeaders() map[string]string {
return map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
"X-App-Version": "3.14.0",
"X-App-Name": "myAudi",
"X-Client-Id": "77869e21-e30a-4a92-b016-48ab7d3db1d8",
"X-Client-Id": audiClientID,
}
req, err := request.New(http.MethodPost, vwidentity.OauthTokenURI, strings.NewReader(body), headers)
if err == nil {
var tokens audiTokenResponse
err = v.DoJSON(req, &tokens)
if err == nil {
v.tokens = tokens
}
}
return err
}
func (v *Audi) getJSON(uri string, res interface{}) error {
req, err := request.New(http.MethodGet, uri, nil, map[string]string{
"Accept": "application/json",
"Authorization": "Bearer " + v.tokens.AccessToken,
})
if err == nil {
err = v.DoJSON(req, &res)
// token expired?
if err != nil {
resp := v.LastResponse()
// handle http 401
if resp != nil && resp.StatusCode == http.StatusUnauthorized {
// use refresh token
err = v.refreshToken()
// re-run auth flow
if err != nil {
err = v.authFlow()
}
}
// retry original requests
if err == nil {
req.Header.Set("Authorization", "Bearer "+v.tokens.AccessToken)
err = v.DoJSON(req, &res)
}
}
}
return err
}
func (v *Audi) vehicles() ([]string, error) {
var res audiVehiclesResponse
uri := fmt.Sprintf("%s/usermanagement/users/v1/Audi/DE/vehicles", vwAPI)
err := v.getJSON(uri, &res)
return res.UserVehicles.Vehicle, err
}
// chargeState implements the Vehicle.ChargeState interface
func (v *Audi) chargeState() (float64, error) {
var res audiChargerResponse
uri := fmt.Sprintf("%s/bs/batterycharge/v1/%s/%s/vehicles/%s/charger", vwAPI, v.brand, v.country, v.vin)
err := v.getJSON(uri, &res)
return float64(res.Charger.Status.BatteryStatusData.StateOfCharge.Content), err
// apiCall provides charger api response
func (v *Audi) apiCall() (interface{}, error) {
res, err := v.api.Charger()
return res, err
}
// ChargeState implements the Vehicle.ChargeState interface
func (v *Audi) ChargeState() (float64, error) {
return v.chargeStateG()
}
// finishTime implements the Vehicle.ChargeFinishTimer interface
func (v *Audi) finishTime() (time.Time, error) {
var res audiChargerResponse
uri := fmt.Sprintf("%s/bs/batterycharge/v1/%s/%s/vehicles/%s/charger", vwAPI, v.brand, v.country, v.vin)
err := v.getJSON(uri, &res)
var timestamp time.Time
if err == nil {
timestamp, err = time.Parse(time.RFC3339, res.Charger.Status.BatteryStatusData.RemainingChargingTime.Timestamp)
res, err := v.apiG()
if res, ok := res.(vw.ChargerResponse); err == nil && ok {
return float64(res.Charger.Status.BatteryStatusData.StateOfCharge.Content), nil
}
return timestamp.Add(time.Duration(res.Charger.Status.BatteryStatusData.RemainingChargingTime.Content) * time.Minute), err
return 0, err
}
// FinishTime implements the Vehicle.ChargeFinishTimer interface
func (v *Audi) FinishTime() (time.Time, error) {
return v.finishTimeG()
res, err := v.apiG()
if res, ok := res.(vw.ChargerResponse); err == nil && ok {
var timestamp time.Time
if err == nil {
timestamp, err = time.Parse(time.RFC3339, res.Charger.Status.BatteryStatusData.RemainingChargingTime.Timestamp)
}
return timestamp.Add(time.Duration(res.Charger.Status.BatteryStatusData.RemainingChargingTime.Content) * time.Minute), err
}
return time.Time{}, err
}

View file

@ -1,7 +1,7 @@
package vehicle
import (
"fmt"
"errors"
"net/http"
"net/http/cookiejar"
"net/url"
@ -12,50 +12,21 @@ import (
"github.com/andig/evcc/provider"
"github.com/andig/evcc/util"
"github.com/andig/evcc/util/request"
"github.com/andig/evcc/vehicle/vwidentity"
"github.com/andig/evcc/vehicle/vw"
"golang.org/x/net/publicsuffix"
)
type vwVehiclesResponse struct {
FullyLoadedVehiclesResponse struct {
CompleteVehicles []struct {
VIN string
}
VehiclesNotFullyLoaded []struct {
VIN string
}
}
}
type vwChargerResponse struct {
ErrorCode int `json:",string"`
// /-/emanager/get-emanager
EManager struct {
RBC struct {
Status struct {
BatteryPercentage int
ChargingRemaningHour int `json:",string"`
ChargingRemaningMinute int `json:",string"`
}
}
}
// /-/vsr/get-vsr
VehicleStatusData struct {
BatteryRange int
BatteryLevel int
}
}
// based on https://github.com/wez3/volkswagen-carnet-client
// https://github.com/trocotronic/weconnect
// VW is an api.Vehicle implementation for VW cars
type VW struct {
*embed
*request.Helper
user, password, vin string
baseURI, csrf string
chargeStateG func() (float64, error)
finishTimeG func() (time.Time, error)
user, password string
clientID string
tokens vw.Tokens
api *vw.API
apiG func() (interface{}, error)
}
func init() {
@ -81,11 +52,10 @@ func NewVWFromConfig(other map[string]interface{}) (api.Vehicle, error) {
Helper: request.NewHelper(log),
user: cc.User,
password: cc.Password,
vin: cc.VIN,
}
v.chargeStateG = provider.NewCached(v.chargeState, cc.Cache).FloatGetter()
v.finishTimeG = provider.NewCached(v.finishTime, cc.Cache).TimeGetter()
v.api = vw.NewAPI(v.Helper, &v.tokens, v.authFlow, v.refreshHeaders, strings.ToUpper(cc.VIN), "VW", "DE")
v.apiG = provider.NewCached(v.apiCall, cc.Cache).InterfaceGetter()
var err error
jar, err := cookiejar.New(&cookiejar.Options{
@ -103,9 +73,9 @@ func NewVWFromConfig(other map[string]interface{}) (api.Vehicle, error) {
}
if err == nil && cc.VIN == "" {
v.vin, err = findVehicle(v.vehicles())
v.api.VIN, err = findVehicle(v.api.Vehicles())
if err == nil {
log.DEBUG.Printf("found vehicle: %v", v.vin)
log.DEBUG.Printf("found vehicle: %v", v.api.VIN)
}
}
@ -114,72 +84,109 @@ func NewVWFromConfig(other map[string]interface{}) (api.Vehicle, error) {
func (v *VW) authFlow() error {
var err error
var uri, body string
var vars vwidentity.FormVars
var uri string
var req *http.Request
var resp *http.Response
// GET www.portal.volkswagen-we.com/portal/de_DE/web/guest/home
uri = "https://www.portal.volkswagen-we.com/portal/de_DE/web/guest/home"
resp, err = v.Get(uri)
if err == nil {
vars, err = vwidentity.FormValues(resp.Body, "meta")
}
// POST www.portal.volkswagen-we.com/portal/en_GB/web/guest/home/-/csrftokenhandling/get-login-url
if err == nil {
uri = "https://www.portal.volkswagen-we.com/portal/en_GB/web/guest/home/-/csrftokenhandling/get-login-url"
if req, err = request.New(http.MethodPost, uri, nil, map[string]string{"X-CSRF-Token": vars.Csrf}); err == nil {
res := struct {
ErrorCode int `json:",string"`
LoginURL struct {
Path string
}
}{}
if err = v.DoJSON(req, &res); err == nil {
uri = strings.ReplaceAll(res.LoginURL.Path, " ", "%20")
if res.ErrorCode != 0 {
err = fmt.Errorf("login url error code: %d", res.ErrorCode)
}
}
}
}
var idToken string
// execute login
challenge, verifier, err := vw.ChallengeVerifier()
query := url.Values(map[string][]string{
"prompt": {"login"},
"state": {vw.RandomString(43)},
"response_type": {"code id_token token"},
"code_challenge_method": {"s256"},
"scope": {"openid profile mbb cars birthdate nickname address phone"},
"code_challenge": {challenge},
"redirect_uri": {"carnet://identity-kit/login"},
"client_id": {"9496332b-ea03-4091-a224-8c746b885068@apps_vw-dilab_com"},
"nonce": {vw.RandomString(43)},
})
uri = "https://identity.vwgroup.io/oidc/v1/authorize?" + query.Encode()
if err == nil {
identity := &vwidentity.Identity{Client: v.Client}
identity := &vw.Identity{Client: v.Client}
resp, err = identity.Login(uri, v.user, v.password)
}
// get base url
if err == nil {
var code, state string
var locationURL *url.URL
// var code string
location := resp.Header.Get("Location")
if locationURL, err = url.Parse(location); err == nil {
code = locationURL.Query().Get("code")
state = locationURL.Query().Get("state")
uri = fmt.Sprintf(
"%s?p_auth=%s&p_p_id=33_WAR_cored5portlet&p_p_lifecycle=1&p_p_state=normal&p_p_mode=view&p_p_col_id=column-1&p_p_col_count=1&_33_WAR_cored5portlet_javax.portlet.action=getLoginStatus",
locationURL.Scheme+"://"+locationURL.Host+locationURL.Path,
state,
)
loc := strings.ReplaceAll(resp.Header.Get("Location"), "#", "?") // convert to parsable url
if locationURL, err := url.Parse(loc); err == nil {
// code = locationURL.Query().Get("code")
idToken = locationURL.Query().Get("id_token")
}
body = fmt.Sprintf("_33_WAR_cored5portlet_code=%s", url.QueryEscape(code))
_ = verifier
// if err == nil {
// data := url.Values(map[string][]string{
// "auth_code": {code},
// "code_verifier": {verifier},
// "id_token": {idToken},
// })
if req, err = request.New(http.MethodPost, uri, strings.NewReader(body), request.URLEncoding); err == nil {
if resp, err = v.Do(req); err == nil {
uri = resp.Header.Get("Location")
// uri := "https://tokenrefreshservice.apps.emea.vwapps.io/exchangeAuthCode"
// req, err = request.New(http.MethodPost, uri, strings.NewReader(data.Encode()), request.URLEncoding)
v.baseURI = uri
v.csrf = vars.Csrf
// // if err == nil {
// // err = v.DoJSON(req, &tokens)
// // if err == nil && tokens.AccessToken == "" {
// // err = errors.New("missing access token")
// // }
// // }
// }
}
// get client id
if err == nil {
data := struct {
AppID string `json:"appId"`
AppName string `json:"appName"`
AppVersion string `json:"appVersion"`
ClientBrand string `json:"client_brand"`
ClientName string `json:"client_name"`
Platform string `json:"platform"`
}{
AppID: "de.volkswagen.car-net.eu.e-remote",
AppName: "We Connect",
AppVersion: "5.3.2",
ClientBrand: "VW",
ClientName: "iPhone",
Platform: "iOS",
}
uri = "https://mbboauth-1d.prd.ece.vwg-connect.com/mbbcoauth/mobile/register/v1"
req, err = request.New(http.MethodPost, uri, request.MarshalJSON(data), request.JSONEncoding)
if err == nil {
data := struct {
ClientID string `json:"client_id"`
}{}
if err = v.DoJSON(req, &data); err == nil && data.ClientID == "" {
err = errors.New("missing client id")
}
v.clientID = data.ClientID
}
if err == nil {
data := url.Values(map[string][]string{
"grant_type": {"id_token"},
"scope": {"sc2:fal"},
"token": {idToken},
})
req, err = request.New(http.MethodPost, vw.OauthTokenURI, strings.NewReader(data.Encode()), map[string]string{
"Content-type": "application/x-www-form-urlencoded",
"X-Client-Id": v.clientID,
})
if err == nil {
if err = v.DoJSON(req, &v.tokens); err == nil && v.tokens.AccessToken == "" {
err = errors.New("missing access token")
}
}
}
}
@ -187,74 +194,40 @@ func (v *VW) authFlow() error {
return err
}
func (v *VW) vehicles() ([]string, error) {
uri := v.baseURI + "/-/mainnavigation/get-fully-loaded-cars"
req, err := request.New(http.MethodPost, uri, nil, map[string]string{
"Accept": "application/json",
"X-CSRF-Token": v.csrf,
})
var res vwVehiclesResponse
if err == nil {
err = v.DoJSON(req, &res)
func (v *VW) refreshHeaders() map[string]string {
return map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
"X-Client-Id": v.clientID,
}
vehicles := make([]string, 0)
for _, v := range res.FullyLoadedVehiclesResponse.CompleteVehicles {
vehicles = append(vehicles, v.VIN)
}
for _, v := range res.FullyLoadedVehiclesResponse.VehiclesNotFullyLoaded {
vehicles = append(vehicles, v.VIN)
}
return vehicles, err
}
// chargeState implements the Vehicle.ChargeState interface
func (v *VW) chargeState() (float64, error) {
uri := v.baseURI + "/-/emanager/get-emanager"
req, err := request.New(http.MethodPost, uri, nil, map[string]string{
"Accept": "application/json",
"X-CSRF-Token": v.csrf,
})
var res vwChargerResponse
if err == nil {
err = v.DoJSON(req, &res)
}
return float64(res.EManager.RBC.Status.BatteryPercentage), err
// apiCall provides charger api response
func (v *VW) apiCall() (interface{}, error) {
res, err := v.api.Charger()
return res, err
}
// ChargeState implements the Vehicle.ChargeState interface
func (v *VW) ChargeState() (float64, error) {
return v.chargeStateG()
}
// finishTime implements the Vehicle.ChargeFinishTimer interface
func (v *VW) finishTime() (time.Time, error) {
uri := v.baseURI + "/-/emanager/get-emanager"
req, err := request.New(http.MethodPost, uri, nil, map[string]string{
"Accept": "application/json",
"X-CSRF-Token": v.csrf,
})
var res vwChargerResponse
if err == nil {
err = v.DoJSON(req, &res)
res, err := v.apiG()
if res, ok := res.(vw.ChargerResponse); err == nil && ok {
return float64(res.Charger.Status.BatteryStatusData.StateOfCharge.Content), nil
}
var duration time.Duration
if err == nil {
hour := res.EManager.RBC.Status.ChargingRemaningHour
min := res.EManager.RBC.Status.ChargingRemaningMinute
duration = time.Hour*time.Duration(hour) + time.Minute*time.Duration(min)
}
return time.Now().Add(duration), err
return 0, err
}
// FinishTime implements the Vehicle.ChargeFinishTimer interface
func (v *VW) FinishTime() (time.Time, error) {
return v.finishTimeG()
res, err := v.apiG()
if res, ok := res.(vw.ChargerResponse); err == nil && ok {
var timestamp time.Time
if err == nil {
timestamp, err = time.Parse(time.RFC3339, res.Charger.Status.BatteryStatusData.RemainingChargingTime.Timestamp)
}
return timestamp.Add(time.Duration(res.Charger.Status.BatteryStatusData.RemainingChargingTime.Content) * time.Minute), err
}
return time.Time{}, err
}

142
vehicle/vw/api.go Normal file
View file

@ -0,0 +1,142 @@
package vw
import (
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/andig/evcc/util/request"
)
// BaseURI is the VW api base URI
const BaseURI = "https://msg.volkswagen.de/fs-car"
// VehiclesResponse is the /usermanagement/users/v1/%s/%s/vehicles api
type VehiclesResponse struct {
UserVehicles struct {
Vehicle []string
}
}
// ChargerResponse is the /bs/batterycharge/v1/%s/%s/vehicles/%s/charger api
type ChargerResponse struct {
Charger struct {
Status struct {
BatteryStatusData struct {
StateOfCharge struct {
Content int
Timestamp string
}
RemainingChargingTime struct {
Content int
Timestamp string
}
}
}
}
}
// API is the VW api client
type API struct {
*request.Helper
tokens *Tokens
authFlow func() error
refreshHeaders func() map[string]string
brand, country string
VIN string
}
// NewAPI creates a new api client
func NewAPI(
helper *request.Helper, tokens *Tokens,
authFlow func() error, refreshHeaders func() map[string]string,
vin, brand, country string,
) *API {
v := &API{
Helper: helper,
tokens: tokens,
authFlow: authFlow,
refreshHeaders: refreshHeaders,
VIN: vin,
brand: brand,
country: country,
}
return v
}
func (v *API) refreshToken() error {
if v.tokens.RefreshToken == "" {
return errors.New("missing refresh token")
}
data := url.Values(map[string][]string{
"grant_type": {"refresh_token"},
"refresh_token": {v.tokens.RefreshToken},
"scope": {"sc2:fal"},
})
req, err := request.New(http.MethodPost, OauthTokenURI, strings.NewReader(data.Encode()), v.refreshHeaders())
if err == nil {
var tokens Tokens
err = v.DoJSON(req, &tokens)
if err == nil {
v.tokens.AccessToken = tokens.AccessToken
if tokens.RefreshToken != "" {
v.tokens.RefreshToken = tokens.RefreshToken
}
}
}
return err
}
func (v *API) getJSON(uri string, res interface{}) error {
req, err := request.New(http.MethodGet, uri, nil, map[string]string{
"Accept": "application/json",
"Authorization": "Bearer " + v.tokens.AccessToken,
})
if err == nil {
err = v.DoJSON(req, &res)
// token expired?
if err != nil {
// handle http 401
if se, ok := err.(request.StatusError); ok && se.StatusCode() == http.StatusUnauthorized {
// use refresh token
if err = v.refreshToken(); err != nil {
// re-run full auth flow
err = v.authFlow()
}
}
// retry original requests
if err == nil {
req.Header.Set("Authorization", "Bearer "+v.tokens.AccessToken)
err = v.DoJSON(req, &res)
}
}
}
return err
}
// Vehicles implements the /vehicles response
func (v *API) Vehicles() ([]string, error) {
var res VehiclesResponse
uri := fmt.Sprintf("%s/usermanagement/users/v1/%s/%s/vehicles", BaseURI, v.brand, v.country)
err := v.getJSON(uri, &res)
return res.UserVehicles.Vehicle, err
}
// Charger implements the /charger response
func (v *API) Charger() (ChargerResponse, error) {
var res ChargerResponse
uri := fmt.Sprintf("%s/bs/batterycharge/v1/%s/%s/vehicles/%s/charger", BaseURI, v.brand, v.country, v.VIN)
err := v.getJSON(uri, &res)
return res, err
}

View file

@ -1,4 +1,4 @@
package vwidentity
package vw
import (
"errors"
@ -7,6 +7,7 @@ import (
"github.com/PuerkitoBio/goquery"
)
// FormVars holds HTML form input values required for login
type FormVars struct {
Action string
Csrf string
@ -14,6 +15,7 @@ type FormVars struct {
Hmac string
}
// FormValues extracts FormVars from given HTML document
func FormValues(reader io.Reader, id string) (FormVars, error) {
vars := FormVars{}

32
vehicle/vw/functions.go Normal file
View file

@ -0,0 +1,32 @@
package vw
import (
"crypto/sha256"
"encoding/base64"
"math/rand"
)
// RandomString creates random string of N integers
func RandomString(n int) string {
var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
s := make([]rune, n)
for i := range s {
s[i] = letters[rand.Intn(len(letters))]
}
return string(s)
}
// ChallengeVerifier returns a challenge/verifier base64-encoded code combination
func ChallengeVerifier() (string, string, error) {
bytes := make([]byte, 32)
n, err := rand.Read(bytes)
if n != 32 || err != nil {
return "", "", err
}
verifier := base64.RawURLEncoding.EncodeToString(bytes)
sha := sha256.Sum256([]byte(verifier))
challenge := base64.RawURLEncoding.EncodeToString(sha[:])
return challenge, verifier, nil
}

View file

@ -1,4 +1,4 @@
package vwidentity
package vw
import (
"net/http"
@ -9,10 +9,33 @@ import (
)
const (
RootURI = "https://identity.vwgroup.io"
// IdentityURI is the VW OIDC identidy provider uri
IdentityURI = "https://identity.vwgroup.io"
// OauthTokenURI is used for refreshing tokens
OauthTokenURI = "https://mbboauth-1d.prd.ece.vwg-connect.com/mbbcoauth/mobile/oauth2/v1/token"
)
// Tokens is an OAuth tokens response
type Tokens struct {
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
IDToken string `json:"id_token"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
// OIDCResponse is the well-known OIDC provider response
// https://{oauth-provider-hostname}/.well-known/openid-configuration
type OIDCResponse struct {
Issuer string `json:"issuer"`
AuthURL string `json:"authorization_endpoint"`
TokenURL string `json:"token_endpoint"`
JWKSURL string `json:"jwks_uri"`
UserInfoURL string `json:"userinfo_endpoint"`
Algorithms []string `json:"id_token_signing_alg_values_supported"`
}
// Identity provides the identity.vwgroup.io login
type Identity struct {
*http.Client
@ -54,7 +77,7 @@ func (v *Identity) Login(uri, user, password string) (*http.Response, error) {
"email": {user},
})
uri = RootURI + vars.Action
uri = IdentityURI + vars.Action
req, err = request.New(http.MethodPost, uri, strings.NewReader(data.Encode()), request.URLEncoding)
if err == nil {
@ -64,7 +87,7 @@ func (v *Identity) Login(uri, user, password string) (*http.Response, error) {
// GET identity.vwgroup.io/signin-service/v1/b7a5bb47-f875-47cf-ab83-2ba3bf6bb738@apps_vw-dilab_com/login/authenticate?relayState=15404cb51c8b4cc5efeee1d2c2a73e5b41562faa&email=...
if err == nil {
uri = RootURI + resp.Header.Get("Location")
uri = IdentityURI + resp.Header.Get("Location")
req, err = http.NewRequest(http.MethodGet, uri, nil)
if err == nil {
@ -86,7 +109,7 @@ func (v *Identity) Login(uri, user, password string) (*http.Response, error) {
"password": {password},
})
uri = RootURI + vars.Action
uri = IdentityURI + vars.Action
req, err = request.New(http.MethodPost, uri, strings.NewReader(data.Encode()), request.URLEncoding)
if err == nil {