From dfc7a46e86cb8037f6ddc70388aff158664181cf Mon Sep 17 00:00:00 2001 From: ummon-org Date: Thu, 25 Dec 2025 13:01:22 +0100 Subject: [PATCH] Add Subaru Solterra (#26175) --- README.md | 2 +- templates/definition/vehicle/subaru.yaml | 14 ++ vehicle/subaru.go | 60 +++++++ vehicle/subaru/api.go | 101 +++++++++++ vehicle/subaru/identity.go | 218 +++++++++++++++++++++++ vehicle/subaru/provider.go | 30 ++++ vehicle/subaru/types.go | 79 ++++++++ 7 files changed, 503 insertions(+), 1 deletion(-) create mode 100644 templates/definition/vehicle/subaru.yaml create mode 100644 vehicle/subaru.go create mode 100644 vehicle/subaru/api.go create mode 100644 vehicle/subaru/identity.go create mode 100644 vehicle/subaru/provider.go create mode 100644 vehicle/subaru/types.go diff --git a/README.md b/README.md index 7e40a6174..76ef61759 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Featured in [PV Magazine](https://www.pv-magazine.de/2022/01/14/mit-open-source- - **sunspec**-compatible inverter or home battery devices - **mbmd**-compatible devices, see [volkszaehler/mbmd](https://github.com/volkszaehler/mbmd#supported-devices) for a complete list - [vehicle](https://docs.evcc.io/en/docs/devices/vehicles) integrations (state of charge, remote charge, battery and preconditioning status): - - Aiways, Audi, BMW, Citroën, Dacia, DS, Fiat, Ford, Hyundai, Jeep, Kia, Mercedes-Benz, MG, Mini, Nissan, NIU, Opel, Peugeot, Polestar, Renault, Seat, Skoda, Smart, Tesla, Toyota, Volkswagen, Volvo, Zero Motorcycles. [Read more.](https://docs.evcc.io/en/docs/devices/vehicles) + - Aiways, Audi, BMW, Citroën, Dacia, DS, Fiat, Ford, Hyundai, Jeep, Kia, Mercedes-Benz, MG, Mini, Nissan, NIU, Opel, Peugeot, Polestar, Renault, Seat, Skoda, Smart, Subaru, Tesla, Toyota, Volkswagen, Volvo, Zero Motorcycles. [Read more.](https://docs.evcc.io/en/docs/devices/vehicles) - **services:** OVMS, Tronity, evNotify, ioBroker.bmw, mg2mqtt, mz2mqtt, TeslaLogger, TeslaMate, Tessi, volvo2mqtt - [plugins](https://docs.evcc.io/en/docs/devices/plugins) for integrating with any charger, smartswitch, heatpump, electric heater, meter, solar- / battery-inverter or vehicle: - Modbus, HTTP, MQTT, JavaScript, WebSocket, Go and shell scripts diff --git a/templates/definition/vehicle/subaru.yaml b/templates/definition/vehicle/subaru.yaml new file mode 100644 index 000000000..9a6d9611f --- /dev/null +++ b/templates/definition/vehicle/subaru.yaml @@ -0,0 +1,14 @@ +template: subaru +products: + - brand: Subaru +requirements: + description: + de: Benötigt Subaru Connected Services Account + en: Requires Subaru Connected Services Account +params: + - preset: vehicle-base + - name: vin + example: JF... +render: | + type: subaru + {{ include "vehicle-base" . }} diff --git a/vehicle/subaru.go b/vehicle/subaru.go new file mode 100644 index 000000000..46ec4ae34 --- /dev/null +++ b/vehicle/subaru.go @@ -0,0 +1,60 @@ +package vehicle + +import ( + "fmt" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/vehicle/subaru" +) + +// Subaru is an api.Vehicle implementation for Subaru cars +type Subaru struct { + *embed + *subaru.Provider +} + +func init() { + registry.Add("subaru", NewSubaruFromConfig) +} + +// NewSubaruFromConfig creates a new vehicle +func NewSubaruFromConfig(other map[string]interface{}) (api.Vehicle, error) { + cc := struct { + embed `mapstructure:",squash"` + User, Password, VIN string + Cache time.Duration + }{ + Cache: interval, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + if cc.User == "" || cc.Password == "" { + return nil, api.ErrMissingCredentials + } + + v := &Subaru{ + embed: &cc.embed, + } + + log := util.NewLogger("subaru").Redact(cc.User, cc.Password, cc.VIN) + identity := subaru.NewIdentity(log) + + err := identity.Login(cc.User, cc.Password) + if err != nil { + return v, fmt.Errorf("login failed: %w", err) + } + + api := subaru.NewAPI(log, identity) + + cc.VIN, err = ensureVehicle(cc.VIN, api.Vehicles) + if err == nil { + v.Provider = subaru.NewProvider(api, cc.VIN, cc.Cache) + } + + return v, err +} diff --git a/vehicle/subaru/api.go b/vehicle/subaru/api.go new file mode 100644 index 000000000..94d5b6882 --- /dev/null +++ b/vehicle/subaru/api.go @@ -0,0 +1,101 @@ +package subaru + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "time" + + "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 ( + BaseUrl = "https://b2c-login.toyota-europe.com" + ApiBaseUrl = "https://ctpa-oneapi.tceu-ctp-prd.toyotaconnectedeurope.io" + AccessTokenPath = "oauth2/realms/root/realms/alliance-subaru/access_token" + AuthenticationPath = "json/realms/root/realms/alliance-subaru/authenticate?authIndexType=service&authIndexValue=oneapp" + AuthorizationPath = "oauth2/realms/root/realms/alliance-subaru/authorize?client_id=8c4921b0b08901fef389ce1af49c4e10.subaru.com&scope=openid+profile+write&response_type=code&redirect_uri=com.subaru.oneapp:/oauth2Callback&code_challenge=plain&code_challenge_method=plain" + VehicleGuidPath = "v2/vehicle/guid" + RemoteElectricStatusPath = "v1/vehicle/electric/status" + ApiKey = "tTZipv6liF74PwMfk9Ed68AQ0bISswwf3iHQdqcF" + ClientRefKey = "2.19.0" +) + +type API struct { + *request.Helper + log *util.Logger + identity *Identity + clientRef string +} + +func NewAPI(log *util.Logger, identity *Identity) *API { + v := &API{ + Helper: request.NewHelper(log), + log: log, + identity: identity, + } + + v.Timeout = 120 * time.Second + + v.Transport = &oauth2.Transport{ + Source: identity, + Base: v.Transport, + } + + h := hmac.New(sha256.New, []byte(ClientRefKey)) + h.Write([]byte(v.identity.uuid)) + v.clientRef = hex.EncodeToString(h.Sum(nil)) + + headers := map[string]string{ + "Accept": request.JSONContent, + "x-guid": v.identity.uuid, + "x-api-key": ApiKey, + "x-client-ref": v.clientRef, + "x-appversion": ClientRefKey, + "X-Appbrand": "S", + } + + v.Transport = &transport.Decorator{ + Decorator: transport.DecorateHeaders(headers), + Base: v.Transport, + } + + return v +} + +func (v *API) IDToken() string { + return v.identity.IDToken() +} + +func (v *API) Vehicles() ([]string, error) { + uri := fmt.Sprintf("%s/%s", ApiBaseUrl, VehicleGuidPath) + + req, err := request.New(http.MethodGet, uri, nil, nil) + var resp Vehicles + if err == nil { + err = v.DoJSON(req, &resp) + } + var vehicles []string + for _, v := range resp.Payload { + vehicles = append(vehicles, v.VIN) + } + return vehicles, err +} + +func (v *API) Status(vin string) (Status, error) { + uri := fmt.Sprintf("%s/%s?vin=%s", ApiBaseUrl, RemoteElectricStatusPath, vin) + + req, err := request.New(http.MethodGet, uri, nil, map[string]string{ + "vin": vin, + }) + var status Status + if err == nil { + err = v.DoJSON(req, &status) + } + return status, err +} diff --git a/vehicle/subaru/identity.go b/vehicle/subaru/identity.go new file mode 100644 index 000000000..b2eb2cbee --- /dev/null +++ b/vehicle/subaru/identity.go @@ -0,0 +1,218 @@ +package subaru + +import ( + "fmt" + "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" + "github.com/golang-jwt/jwt/v5" + "golang.org/x/oauth2" +) + +const ( + APIVersion = "protocol=1.0,resource=2.1" + ClientID = "8c4921b0b08901fef389ce1af49c4e10.subaru.com" + Scope = "openid profile vehicles" + RedirectURI = "com.subaru.oneapp:/oauth2Callback" + AppAuthorization = "Basic OGM0OTIxYjBiMDg5MDFmZWYzODljZTFhZjQ5YzRlMTAuc3ViYXJ1LmNvbTpJaGNkcjV4YmhIYlRSMk9aOGdRa3YyNTZicmhTYjc=" +) + +type Identity struct { + log *util.Logger + *request.Helper + oauth2.TokenSource + uuid string + idToken string +} + +func NewIdentity(log *util.Logger) *Identity { + return &Identity{ + log: log, + Helper: request.NewHelper(log), + } +} + +func (v *Identity) IDToken() string { + return v.idToken +} + +func (v *Identity) authenticate(initial Auth, user, password string) (*Token, error) { + uri := fmt.Sprintf("%s/%s", BaseUrl, AuthenticationPath) + + auth := initial + passwordSet := false + + for { + for i, cb := range auth.Callbacks { + switch cb.Type { + case "NameCallback": + if outputValue, ok := cb.Output[0].Value.(string); ok && outputValue == "User Name" { + auth.Callbacks[i].Input[0].Value = user + } + case "PasswordCallback": + auth.Callbacks[i].Input[0].Value = password + passwordSet = true + } + } + + req, err := request.New(http.MethodPost, uri, request.MarshalJSON(auth), request.JSONEncoding) + if err != nil { + return nil, err + } + + if passwordSet { + var token Token + if err := v.DoJSON(req, &token); err != nil { + return nil, err + } + return &token, nil + } + + var next Auth + if err := v.DoJSON(req, &next); err != nil { + return nil, err + } + auth = next + } +} + +func (v *Identity) authorize(token Token) (string, error) { + uri := fmt.Sprintf("%s/%s", BaseUrl, AuthorizationPath) + req, err := request.New(http.MethodGet, uri, nil, map[string]string{ + "Cookie": fmt.Sprintf("iPlanetDirectoryPro=%s", token.TokenID), + }) + if err != nil { + return "", err + } + + originalCheckRedirect := v.Client.CheckRedirect + defer func() { v.Client.CheckRedirect = originalCheckRedirect }() + + var param request.InterceptResult + v.Client.CheckRedirect, param = request.InterceptRedirect("code", true) + var code string + if _, err = v.Do(req); err == nil { + code, err = param() + } + return code, err +} + +func (v *Identity) fetchTokenCredentials(code string) error { + uri := fmt.Sprintf("%s/%s", BaseUrl, AccessTokenPath) + + data := url.Values{ + "client_id": {ClientID}, + "code": {code}, + "redirect_uri": {RedirectURI}, + "grant_type": {"authorization_code"}, + "code_verifier": {"plain"}, + } + + headers := make(map[string]string) + for k, v := range request.URLEncoding { + headers[k] = v + } + headers["Authorization"] = AppAuthorization + req, err := request.New(http.MethodPost, uri, strings.NewReader(data.Encode()), headers) + if err != nil { + return err + } + + var res struct { + oauth2.Token + IDToken string `json:"id_token"` + } + if err = v.DoJSON(req, &res); err != nil { + return fmt.Errorf("failed to fetch token credentials: %w", err) + } + + v.idToken = res.IDToken + + parser := jwt.NewParser(jwt.WithoutClaimsValidation()) + token, _, err := parser.ParseUnverified(res.IDToken, jwt.MapClaims{}) + if err != nil { + return fmt.Errorf("failed to parse id token: %w", err) + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return fmt.Errorf("invalid token claims format") + } + + uuid, ok := claims["uuid"].(string) + if !ok { + return fmt.Errorf("uuid claim missing or invalid in token") + } + + v.uuid = uuid + v.TokenSource = oauth.RefreshTokenSource(util.TokenWithExpiry(&res.Token), v) + return nil +} + +func (v *Identity) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) { + uri := fmt.Sprintf("%s/%s", BaseUrl, AccessTokenPath) + data := url.Values{ + "client_id": {ClientID}, + "redirect_uri": {RedirectURI}, + "grant_type": {"refresh_token"}, + "refresh_token": {token.RefreshToken}, + } + + headers := make(map[string]string) + for k, v := range request.URLEncoding { + headers[k] = v + } + headers["Authorization"] = AppAuthorization + var res oauth2.Token + req, err := request.New(http.MethodPost, uri, strings.NewReader(data.Encode()), headers) + if err != nil { + return nil, err + } + if err := v.DoJSON(req, &res); err != nil { + return nil, err + } + return util.TokenWithExpiry(&res), nil +} + +func (v *Identity) Login(user, password string) error { + uri := fmt.Sprintf("%s/%s", BaseUrl, AuthenticationPath) + req, err := request.New(http.MethodPost, uri, nil, map[string]string{ + "Accept": "application/json", + "X-Region": "EU", + "Region": "EU", + "Brand": "S", + "X-Brand": "S", + }) + if err != nil { + return fmt.Errorf("failed to create authentication request: %w", err) + } + + var auth Auth + if err = v.DoJSON(req, &auth); err != nil { + return fmt.Errorf("failed to get initial auth response: %w", err) + } + + token, err := v.authenticate(auth, user, password) + if err != nil { + return fmt.Errorf("authentication failed: %w", err) + } + + if err := token.Error(); err != nil { + return fmt.Errorf("token error: %w", err) + } + + code, err := v.authorize(*token) + if err != nil { + return fmt.Errorf("authorization failed: %w", err) + } + + if err = v.fetchTokenCredentials(code); err != nil { + return fmt.Errorf("failed to fetch token credentials: %w", err) + } + + return nil +} diff --git a/vehicle/subaru/provider.go b/vehicle/subaru/provider.go new file mode 100644 index 000000000..cf8e95341 --- /dev/null +++ b/vehicle/subaru/provider.go @@ -0,0 +1,30 @@ +package subaru + +import ( + "time" + + "github.com/evcc-io/evcc/util" +) + +type Provider struct { + status func() (Status, error) +} + +func NewProvider(api *API, vin string, cache time.Duration) *Provider { + impl := &Provider{ + status: util.Cached(func() (Status, error) { + return api.Status(vin) + }, cache), + } + return impl +} + +func (v *Provider) Soc() (float64, error) { + res, err := v.status() + return float64(res.Payload.BatteryLevel), err +} + +func (v *Provider) Range() (int64, error) { + res, err := v.status() + return int64(res.Payload.EvRangeWithAc.Value), err +} diff --git a/vehicle/subaru/types.go b/vehicle/subaru/types.go new file mode 100644 index 000000000..5601bd0bc --- /dev/null +++ b/vehicle/subaru/types.go @@ -0,0 +1,79 @@ +package subaru + +import ( + "fmt" + "strings" +) + +type Status struct { + Status struct { + Messages []struct { + ResponseCode string `json:"responseCode"` + Description string `json:"description"` + DetailedDescription string `json:"detailedDescription"` + } `json:"messages"` + } `json:"status"` + Payload struct { + BatteryLevel int `json:"batteryLevel"` + ChargingStatus string `json:"chargingStatus"` + EvRange EvRange `json:"evRange"` + EvRangeWithAc EvRange `json:"evRangeWithAc"` + LastUpdateTimestamp string `json:"lastUpdateTimestamp"` + } `json:"payload"` +} + +type EvRange struct { + Unit string `json:"unit"` + Value float64 `json:"value"` +} + +type Auth struct { + AuthID string `json:"authId"` + Callbacks []AuthCallback `json:"callbacks"` +} + +type AuthCallback struct { + Id int8 `json:"_id"` + Type string `json:"type"` + Output []AuthCallbackValue `json:"output"` + Input []AuthCallbackValue `json:"input"` +} + +type AuthCallbackValue struct { + Name string `json:"name"` + Value interface{} `json:"value"` +} + +type Token struct { + TokenID string `json:"tokenId"` + SuccessURL string `json:"successUrl"` + Code int `json:"code"` // error response + Reason string `json:"reason"` // error response + Message string `json:"message"` // error response +} + +func (t *Token) SessionExpired() bool { + return strings.EqualFold(t.Message, "Session has timed out") +} + +func (t *Token) Error() error { + if t.Code == 0 { + return nil + } + return fmt.Errorf("%s: %s", t.Reason, t.Message) +} + +type Vehicles struct { + Status struct { + Messages []struct { + ResponseCode string `json:"responseCode"` + Description string `json:"description"` + DetailedDescription string `json:"detailedDescription"` + } `json:"messages"` + } `json:"status"` + Payload []Vehicle `json:"payload"` +} + +type Vehicle struct { + VIN string `json:"vin"` +}