Add Subaru Solterra (#26175)

This commit is contained in:
ummon-org 2025-12-25 13:01:22 +01:00 • committed by GitHub
parent 50f620a201
commit dfc7a46e86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 503 additions and 1 deletions

View file

@ -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

View file

@ -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" . }}

60
vehicle/subaru.go Normal file
View file

@ -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
}

101
vehicle/subaru/api.go Normal file
View file

@ -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
}

218
vehicle/subaru/identity.go Normal file
View file

@ -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
}

View file

@ -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
}

79
vehicle/subaru/types.go Normal file
View file

@ -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"`
}