Replace VW We with VW Id api (#367)
This commit is contained in:
parent
2d7c6d2b44
commit
95cc086226
9 changed files with 462 additions and 323 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
234
vehicle/audi.go
234
vehicle/audi.go
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
275
vehicle/vw.go
275
vehicle/vw.go
|
|
@ -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
142
vehicle/vw/api.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
32
vehicle/vw/functions.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
Loading…
Add table
Add a link
Reference in a new issue