Add Subaru Solterra (#26175)
This commit is contained in:
parent
50f620a201
commit
dfc7a46e86
7 changed files with 503 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
14
templates/definition/vehicle/subaru.yaml
Normal file
14
templates/definition/vehicle/subaru.yaml
Normal 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
60
vehicle/subaru.go
Normal 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
101
vehicle/subaru/api.go
Normal 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
218
vehicle/subaru/identity.go
Normal 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
|
||||
}
|
||||
30
vehicle/subaru/provider.go
Normal file
30
vehicle/subaru/provider.go
Normal 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
79
vehicle/subaru/types.go
Normal 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"`
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue