Ford: remove legacy integration (BC) (#25516)

This commit is contained in:
andig 2025-11-24 11:03:54 +01:00 • committed by GitHub
parent 8d94166d93
commit 628a12ee45
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 0 additions and 702 deletions

View file

@ -1,21 +0,0 @@
template: ford
deprecated: true
products:
- brand: Ford
requirements:
description:
de: "Hinweis: Ford hat kürzlich den API-Zugriff für seine Benutzer deaktiviert."
en: "Note: Ford has recently disabled API-access for their users."
params:
- preset: vehicle-base
- name: vin
example: WF0FXX...
- name: domain
type: choice
choice: ["com", "de"]
default: com
required: true
render: |
type: ford
{{ include "vehicle-base" . }}
domain: {{ .domain }}

View file

@ -1,73 +0,0 @@
package vehicle
import (
"fmt"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/vehicle/ford"
"github.com/evcc-io/evcc/vehicle/ford/autonomic"
)
// https://github.com/d4v3y0rk/ffpass-module
// https://github.com/ianjwhite99/connected-car-node-sdk
// https://github.com/TA2k/ioBroker.ford
// Ford is an api.Vehicle implementation for Ford cars
type Ford struct {
*embed
*ford.Provider
}
func init() {
registry.Add("ford", NewFordFromConfig)
}
// NewFordFromConfig creates a new vehicle
func NewFordFromConfig(other map[string]any) (api.Vehicle, error) {
cc := struct {
embed `mapstructure:",squash"`
User, Password, VIN string
Domain string
Cache time.Duration
}{
Domain: "com",
Cache: interval,
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
if cc.User == "" || cc.Password == "" {
return nil, api.ErrMissingCredentials
}
v := &Ford{
embed: &cc.embed,
}
log := util.NewLogger("ford").Redact(cc.User, cc.Password, cc.VIN)
identity := ford.NewIdentity(log, cc.User, cc.Password, cc.Domain)
err := identity.Login()
if err != nil {
return nil, fmt.Errorf("login failed: %w", err)
}
api := ford.NewAPI(log, identity)
cc.VIN, err = ensureVehicle(cc.VIN, api.Vehicles)
if err != nil {
return nil, err
}
autoIdentity, err := autonomic.NewIdentity(log, identity)
if err == nil {
api := autonomic.NewAPI(log, autoIdentity)
v.Provider = ford.NewProvider(api, cc.VIN, cc.Cache)
}
return v, err
}

View file

@ -1,73 +0,0 @@
package ford
import (
"fmt"
"net/http"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/transport"
"golang.org/x/oauth2"
)
const ApiURI = "https://usapi.cv.ford.com"
// API is the Ford api client
type API struct {
*request.Helper
}
// NewAPI creates a new api client
func NewAPI(log *util.Logger, ts oauth2.TokenSource) *API {
v := &API{
Helper: request.NewHelper(log),
}
v.Client.Transport = &transport.Decorator{
Decorator: func(req *http.Request) error {
token, err := ts.Token()
if err != nil {
return err
}
for k, v := range map[string]string{
"Content-type": request.JSONContent,
"User-Agent": "FordPass/5 CFNetwork/1333.0.4 Darwin/21.5.0",
"locale": "de-DE",
"Application-Id": ApplicationID,
"Auth-Token": token.AccessToken,
"CountryCode": "DEU",
} {
req.Header.Set(k, v)
}
return nil
},
Base: v.Client.Transport,
}
return v
}
// Vehicles returns the list of user vehicles
func (v *API) Vehicles() ([]string, error) {
var res []string
data := map[string]string{
"dashboardRefreshRequest": "All",
}
uri := fmt.Sprintf("%s/api/expdashboard/v1/details", TokenURI)
req, err := request.New(http.MethodPost, uri, request.MarshalJSON(data), request.JSONEncoding)
if err == nil {
var resp VehiclesResponse
if err = v.DoJSON(req, &resp); err == nil {
for _, v := range resp.UserVehicles.VehicleDetails {
res = append(res, v.VIN)
}
}
}
return res, err
}

View file

@ -1,87 +0,0 @@
package autonomic
import (
"fmt"
"net/http"
"strings"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/transport"
"golang.org/x/oauth2"
)
const ApiURI = "https://api.autonomic.ai/v1beta/telemetry/sources/fordpass"
var (
ApplicationID = "667D773E-1BDC-4139-8AD0-2B16474E8DC7"
Dyna = "MT_3_30_2352378557_3-0_" + "uuidv4()" + "_0_789_87"
)
// API is the Ford api client
type API struct {
*request.Helper
}
// NewAPI creates a new api client
func NewAPI(log *util.Logger, ts oauth2.TokenSource) *API {
v := &API{
Helper: request.NewHelper(log),
}
v.Client.Transport = &transport.Decorator{
Decorator: func(req *http.Request) error {
token, err := ts.Token()
if err != nil {
return err
}
for k, v := range map[string]string{
"Content-type": request.JSONContent,
"Application-Id": ApplicationID,
"x-dynatrace": Dyna,
"Authorization": "Bearer " + token.AccessToken,
} {
req.Header.Set(k, v)
}
return nil
},
Base: v.Client.Transport,
}
return v
}
// Status retrieves a metrics result using :query
func (v *API) Status(vin string) (MetricsResponse, error) {
var res MetricsResponse
uri := fmt.Sprintf("%s/vehicles/%s:query", ApiURI, vin)
req, err := request.New(http.MethodPost, uri, strings.NewReader("{}"), request.JSONEncoding)
if err == nil {
err = v.DoJSON(req, &res)
}
return res, err
}
// Refresh executes the refresh command
func (v *API) Refresh(vin string) (any, error) {
var res map[string]any
data := map[string]any{
"properties": struct{}{},
"tags": struct{}{},
"type": "statusRefresh",
"wakeUp": true,
}
uri := fmt.Sprintf("%s/vehicles/%s/command", ApiURI, vin)
req, err := request.New(http.MethodPost, uri, request.MarshalJSON(data), request.JSONEncoding)
if err == nil {
err = v.DoJSON(req, &res)
}
return res, err
}

View file

@ -1,78 +0,0 @@
package autonomic
import (
"net/http"
"net/url"
"strings"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/oauth"
"github.com/evcc-io/evcc/util/request"
"golang.org/x/oauth2"
)
// https://accounts.autonomic.ai/v1/auth/oidc/.well-known/openid-configuration/
var OAuth2Config = &oauth2.Config{
Endpoint: oauth2.Endpoint{
AuthURL: "https://accounts.autonomic.ai/v1/auth/oidc/authorize",
TokenURL: "https://accounts.autonomic.ai/v1/auth/oidc/token",
},
Scopes: []string{"openid"},
}
type Identity struct {
*request.Helper
ts oauth2.TokenSource
oauth2.TokenSource
}
// NewIdentity creates autonomic token source
func NewIdentity(log *util.Logger, ts oauth2.TokenSource) (*Identity, error) {
v := &Identity{
Helper: request.NewHelper(log),
ts: ts,
}
token, err := ts.Token()
if err != nil {
return nil, err
}
auto, err := v.exchange(token)
if err != nil {
return nil, err
}
v.TokenSource = oauth.RefreshTokenSource(auto, v)
return v, nil
}
// exchange authenticates with username/password to get new aws credentials
func (v *Identity) exchange(token *oauth2.Token) (*oauth2.Token, error) {
data := url.Values{
"subject_token": {token.AccessToken},
"subject_issuer": {"fordpass"},
"client_id": {"fordpass-prod"},
"grant_type": {"urn:ietf:params:oauth:grant-type:token-exchange"},
"subject_token_type": {"urn:ietf:params:oauth:token-type:jwt"},
}
var res *oauth2.Token
req, err := request.New(http.MethodPost, OAuth2Config.Endpoint.TokenURL, strings.NewReader(data.Encode()), request.URLEncoding)
if err == nil {
err = v.DoJSON(req, &res)
}
return util.TokenWithExpiry(res), err
}
// RefreshToken implements oauth.TokenRefresher
func (v *Identity) RefreshToken(_ *oauth2.Token) (*oauth2.Token, error) {
token, err := v.ts.Token()
if err != nil {
return nil, err
}
return v.exchange(token)
}

View file

@ -1,32 +0,0 @@
package autonomic
import "time"
type IntValue struct {
UpdateTime time.Time
Value int
}
type FloatValue struct {
UpdateTime time.Time
Value float64
}
type StringValue struct {
UpdateTime time.Time
Value string
}
type MetricsResponse struct {
Metrics struct {
Position struct {
Value struct {
Location struct {
Lat, Lon float64
}
}
}
Odometer FloatValue
XevPlugChargerStatus StringValue
XevBatteryRange FloatValue
XevBatteryStateOfCharge FloatValue
}
}

View file

@ -1,229 +0,0 @@
package ford
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/cookiejar"
"net/url"
"regexp"
"strings"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/oauth"
"github.com/evcc-io/evcc/util/request"
"github.com/samber/lo"
"golang.org/x/oauth2"
)
const (
TokenURI = "https://api.mps.ford.com"
LoginUriTmpl = "https://login.ford.%s/4566605f-43a7-400a-946e-89cc9fdb0bd7/B2C_1A_SignInSignUp_de-DE"
ClientID = "09852200-05fd-41f6-8c21-d36d3497dc64"
ApplicationID = "1E8C7794-FF5F-49BC-9596-A1E0C86C5B19"
)
var loginHeaders = map[string]string{
"Accept": "*/*",
"Accept-Language": "en-us",
"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1",
}
func OAuth2Config(domain string) *oauth2.Config {
uri := loginUri(domain)
return &oauth2.Config{
ClientID: ClientID,
Endpoint: oauth2.Endpoint{
AuthURL: fmt.Sprintf("%s/oauth2/v2.0/authorize", uri),
TokenURL: fmt.Sprintf("%s/oauth2/v2.0/token", uri),
},
RedirectURL: "fordapp://userauthorized",
Scopes: []string{ClientID, "openid"},
}
}
func loginUri(domain string) string {
return fmt.Sprintf(LoginUriTmpl, domain)
}
type Settings struct {
TransId string `json:"transId"`
Csrf string `json:"csrf"`
}
type Identity struct {
*request.Helper
user, password, domain string
oauth2.TokenSource
}
// NewIdentity creates Ford identity
func NewIdentity(log *util.Logger, user, password, domain string) *Identity {
return &Identity{
Helper: request.NewHelper(log),
user: user,
password: password,
domain: domain,
}
}
// Login authenticates with username/password to get new aws credentials
func (v *Identity) Login() error {
token, err := v.login()
if err == nil {
v.TokenSource = oauth.RefreshTokenSource(token, v)
}
return err
}
// login authenticates with username/password to get new token
func (v *Identity) login() (*oauth2.Token, error) {
oc := OAuth2Config(v.domain)
cv := oauth2.GenerateVerifier()
state := lo.RandomString(16, lo.AlphanumericCharset)
uri := oc.AuthCodeURL(state, oauth2.S256ChallengeOption(cv),
oauth2.SetAuthURLParam("max_age", "3600"),
oauth2.SetAuthURLParam("ui_locales", "de-DE"),
oauth2.SetAuthURLParam("language_code", "de-DE"),
oauth2.SetAuthURLParam("country_code", "DE"),
oauth2.SetAuthURLParam("ford_application_id", ApplicationID),
)
v.Jar, _ = cookiejar.New(nil)
defer func() { v.Jar = nil }()
var body []byte
req, err := request.New(http.MethodGet, uri, nil, loginHeaders)
if err == nil {
body, err = v.DoBody(req)
}
if err != nil {
return nil, err
}
match := regexp.MustCompile(`var SETTINGS = (\{[^;]*\});`).FindSubmatch(body)
if len(match) < 2 {
return nil, errors.New("missing settings variable")
}
var settings Settings
if err := json.Unmarshal(match[1], &settings); err != nil {
return nil, err
}
data := url.Values{
"request_type": {"RESPONSE"},
"signInName": {v.user},
"password": {v.password},
}
v.Client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if req.URL.Scheme != "https" {
return http.ErrUseLastResponse
}
return nil
}
defer func() { v.Client.CheckRedirect = nil }()
uri2 := fmt.Sprintf("%s/SelfAsserted?tx=%s&p=B2C_1A_SignInSignUp_de-DE", loginUri(v.domain), settings.TransId)
req, err = request.New(http.MethodPost, uri2, strings.NewReader(data.Encode()), request.URLEncoding, map[string]string{
"Accept": "*/*",
"Accept-Language": "en-us",
"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1",
"Origin": "https://login.ford.com",
"Referer": uri,
"X-Csrf-Token": settings.Csrf,
})
if err == nil {
var resp *http.Response
if resp, err = v.Do(req); err == nil {
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, errors.New("self-assert failed")
}
}
}
uri3 := fmt.Sprintf("%s/api/CombinedSigninAndSignup/confirmed?rememberMe=false&csrf_token=%s&tx=%s&p=B2C_1A_SignInSignUp_de-DE", loginUri(v.domain), settings.Csrf, settings.TransId)
req, err = request.New(http.MethodGet, uri3, nil, request.URLEncoding, map[string]string{
"Origin": "https://login.ford.com",
"Referer": uri,
"X-Csrf-Token": settings.Csrf,
})
var loc *url.URL
if err == nil {
var resp *http.Response
if resp, err = v.Do(req); err == nil {
defer resp.Body.Close()
loc, err = url.Parse(resp.Header.Get("location"))
}
}
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(
context.WithValue(context.Background(), oauth2.HTTPClient, v.Client),
request.Timeout,
)
defer cancel()
code := loc.Query().Get("code")
if code == "" {
return nil, errors.New("could not obtain auth code- check user and password")
}
tok, err := oc.Exchange(ctx, code, oauth2.VerifierOption(cv))
// exchange code for api token
var token oauth2.Token
if err == nil {
data := map[string]string{
"idpToken": tok.AccessToken,
}
uri := fmt.Sprintf("%s/api/token/v2/cat-with-b2c-access-token", TokenURI)
var req *http.Request
req, err = request.New(http.MethodPost, uri, request.MarshalJSON(data), map[string]string{
"Content-type": request.JSONContent,
"Application-Id": ApplicationID,
})
if err == nil {
err = v.DoJSON(req, &token)
}
}
return util.TokenWithExpiry(&token), err
}
// RefreshToken implements oauth.TokenRefresher
func (v *Identity) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) {
data := map[string]string{
"refresh_token": token.RefreshToken,
}
uri := fmt.Sprintf("%s/api/token/v2/cat-with-refresh-token", TokenURI)
req, _ := request.New(http.MethodPost, uri, request.MarshalJSON(data), map[string]string{
"Content-type": request.JSONContent,
"Application-Id": ApplicationID,
})
var res *oauth2.Token
err := v.DoJSON(req, &res)
if err != nil {
res, err = v.login()
}
return util.TokenWithExpiry(res), err
}

View file

@ -1,100 +0,0 @@
package ford
import (
"fmt"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/vehicle/ford/autonomic"
)
type Provider struct {
statusG func() (autonomic.MetricsResponse, error)
refreshG func() error
}
func NewProvider(api *autonomic.API, vin string, cache time.Duration) *Provider {
impl := &Provider{
statusG: util.Cached(func() (autonomic.MetricsResponse, error) {
return api.Status(vin)
}, cache),
refreshG: func() error {
_, err := api.Refresh(vin)
return err
},
}
return impl
}
var _ api.Battery = (*Provider)(nil)
// Soc implements the api.Battery interface
func (v *Provider) Soc() (float64, error) {
res, err := v.statusG()
if err == nil {
return res.Metrics.XevBatteryStateOfCharge.Value, nil
}
return 0, err
}
var _ api.VehicleRange = (*Provider)(nil)
// Range implements the api.VehicleRange interface
func (v *Provider) Range() (int64, error) {
res, err := v.statusG()
if err == nil {
return int64(res.Metrics.XevBatteryRange.Value), nil
}
return 0, err
}
var _ api.ChargeState = (*Provider)(nil)
// Status implements the api.ChargeState interface
func (v *Provider) Status() (api.ChargeStatus, error) {
status := api.StatusNone
res, err := v.statusG()
if err == nil {
switch res.Metrics.XevPlugChargerStatus.Value {
case "DISCONNECTED":
status = api.StatusA // disconnected
case "CONNECTED":
status = api.StatusB // connected, not charging
case "CHARGING", "CHARGINGAC":
status = api.StatusC // charging
default:
err = fmt.Errorf("unknown charge status: %s", res.Metrics.XevPlugChargerStatus.Value)
}
}
return status, err
}
var _ api.VehicleOdometer = (*Provider)(nil)
// Odometer implements the api.VehicleOdometer interface
func (v *Provider) Odometer() (float64, error) {
res, err := v.statusG()
return res.Metrics.Odometer.Value, err
}
var _ api.VehiclePosition = (*Provider)(nil)
// Position implements the api.VehiclePosition interface
func (v *Provider) Position() (float64, float64, error) {
res, err := v.statusG()
loc := res.Metrics.Position.Value.Location
return loc.Lat, loc.Lon, err
}
var _ api.Resurrector = (*Provider)(nil)
// WakeUp implements the api.Resurrector interface
func (v *Provider) WakeUp() error {
return v.refreshG()
}

View file

@ -1,9 +0,0 @@
package ford
type VehiclesResponse struct {
UserVehicles struct {
VehicleDetails []struct {
VIN string
}
}
}