diff --git a/templates/definition/vehicle/hyundai-us.yaml b/templates/definition/vehicle/hyundai-us.yaml new file mode 100644 index 000000000..f1e20d4e5 --- /dev/null +++ b/templates/definition/vehicle/hyundai-us.yaml @@ -0,0 +1,10 @@ +template: hyundai-us +products: + - brand: Hyundai (US) + description: + generic: Bluelink +params: + - preset: vehicle-base +render: | + type: hyundai-us + {{ include "vehicle-base" . }} diff --git a/vehicle/bluelink_us.go b/vehicle/bluelink_us.go new file mode 100644 index 000000000..b8f7772bd --- /dev/null +++ b/vehicle/bluelink_us.go @@ -0,0 +1,90 @@ +package vehicle + +import ( + "errors" + "fmt" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/vehicle/bluelink_us" +) + +type BluelinkUS struct { + *embed + *bluelink_us.Provider +} + +func init() { + registry.Add("hyundai-us", NewHyundaiUSFromConfig) +} + +func NewHyundaiUSFromConfig(other map[string]any) (api.Vehicle, error) { + cc := struct { + embed `mapstructure:",squash"` + User, Password string + Pin string + 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 + } + if cc.Pin == "" { + return nil, errors.New("PIN is required") + } + + log := util.NewLogger("hyundai-us").Redact(cc.User, cc.Password, cc.VIN, cc.Pin) + + identity := bluelink_us.NewIdentity(log, cc.User, cc.Password) + if err := identity.Login(); err != nil { + return nil, fmt.Errorf("login failed: %w", err) + } + + // Create temporary API to fetch vehicles (without vehicle-specific headers) + tempCfg := bluelink_us.APIConfig{ + User: cc.User, + } + tempAPI := bluelink_us.NewAPI(log, identity, tempCfg) + vehicles, err := tempAPI.Vehicles() + if err != nil { + return nil, fmt.Errorf("cannot get vehicles: %w", err) + } + + // Find matching vehicle by VIN + vehicle, err := ensureVehicleEx( + cc.VIN, + func() ([]bluelink_us.Vehicle, error) { return vehicles, nil }, + func(v bluelink_us.Vehicle) (string, error) { return v.VIN, nil }, + ) + if err != nil { + return nil, err + } + + // Create full API with vehicle-specific headers + apiCfg := bluelink_us.APIConfig{ + User: cc.User, + Pin: cc.Pin, + RegistrationID: vehicle.RegID, + VIN: vehicle.VIN, + Generation: vehicle.VehicleGeneration, + } + vehicleAPI := bluelink_us.NewAPI(log, identity, apiCfg) + + v := &BluelinkUS{ + embed: &cc.embed, + Provider: bluelink_us.NewProvider(vehicleAPI, cc.Cache), + } + + if v.Title_ == "" { + v.Title_ = vehicle.NickName + } + + return v, nil +} diff --git a/vehicle/bluelink_us/api.go b/vehicle/bluelink_us/api.go new file mode 100644 index 000000000..b2ae1500d --- /dev/null +++ b/vehicle/bluelink_us/api.go @@ -0,0 +1,143 @@ +package bluelink_us + +import ( + "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 ( + ApiURL = "/ac/v2/" + EnrollmentURL = "enrollment/details/%s" + VehicleStatusURL = "rcs/rvs/vehicleStatus" + PositionURL = "rcs/rfc/findMyCar" + ChargeStartURL = "evc/charge/start" + ChargeStopURL = "evc/charge/stop" +) + +type APIConfig struct { + User string + Pin string + RegistrationID string + VIN string + Generation string +} + +type API struct { + *request.Helper + baseURI string + user string +} + +func NewAPI(log *util.Logger, ts oauth2.TokenSource, cfg APIConfig) *API { + v := &API{ + Helper: request.NewHelper(log), + baseURI: BaseURL + ApiURL, + user: cfg.User, + } + + // API can be slow + v.Client.Timeout = 30 * time.Second + + // Add transport decorator for auth headers + v.Client.Transport = &transport.Decorator{ + Decorator: func(req *http.Request) error { + token, err := ts.Token() + if err != nil { + return err + } + + for k, v := range BaseHeaders() { + req.Header.Set(k, v) + } + req.Header.Set("username", cfg.User) + req.Header.Set("accessToken", token.AccessToken) + + // Vehicle-specific headers + if cfg.Pin != "" { + req.Header.Set("blueLinkServicePin", cfg.Pin) + } + if cfg.RegistrationID != "" { + req.Header.Set("registrationId", cfg.RegistrationID) + } + if cfg.Generation != "" { + req.Header.Set("gen", cfg.Generation) + } + if cfg.VIN != "" { + req.Header.Set("vin", cfg.VIN) + } + + return nil + }, + Base: v.Client.Transport, + } + + return v +} + +func (v *API) Vehicles() ([]Vehicle, error) { + var res EnrollmentResponse + + uri := v.baseURI + fmt.Sprintf(EnrollmentURL, v.user) + err := v.GetJSON(uri, &res) + + vehicles := make([]Vehicle, 0, len(res.EnrolledVehicleDetails)) + for _, entry := range res.EnrolledVehicleDetails { + vehicles = append(vehicles, entry.VehicleDetails) + } + + return vehicles, err +} + +func (v *API) Status() (VehicleStatus, error) { + var res VehicleStatusResponse + + uri := v.baseURI + VehicleStatusURL + err := v.GetJSON(uri, &res) + + return res.VehicleStatus, err +} + +func (v *API) Position() (PositionResponse, error) { + var res PositionResponse + + uri := v.baseURI + PositionURL + err := v.GetJSON(uri, &res) + + return res, err +} + +func (v *API) ChargeStart() error { + uri := v.baseURI + ChargeStartURL + + req, err := request.New(http.MethodPost, uri, nil, nil) + if err != nil { + return err + } + + resp, err := v.Do(req) + if err == nil { + _ = resp.Body.Close() + } + return err +} + +func (v *API) ChargeStop() error { + uri := v.baseURI + ChargeStopURL + + req, err := request.New(http.MethodPost, uri, nil, nil) + if err != nil { + return err + } + + resp, err := v.Do(req) + if err == nil { + _ = resp.Body.Close() + } + return err +} diff --git a/vehicle/bluelink_us/identity.go b/vehicle/bluelink_us/identity.go new file mode 100644 index 000000000..ba1474b24 --- /dev/null +++ b/vehicle/bluelink_us/identity.go @@ -0,0 +1,104 @@ +package bluelink_us + +import ( + "fmt" + "net/http" + "strconv" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/oauth" + "github.com/evcc-io/evcc/util/request" + "golang.org/x/oauth2" +) + +const ( + BaseURL = "https://api.telematics.hyundaiusa.com" + TokenURL = "/v2/ac/oauth/token" + ClientID = "m66129Bb-em93-SPAHYN-bZ91-am4540zp19920" + ClientSecret = "v558o935-6nne-423i-baa8" +) + +// BaseHeaders returns the common headers required for all API requests +func BaseHeaders() map[string]string { + // Calculate UTC offset + _, offset := time.Now().Zone() + offsetHours := fmt.Sprintf("%d", offset/3600) + + return map[string]string{ + "content-type": "application/json;charset=UTF-8", + "accept": "application/json, text/plain, */*", + "from": "SPA", + "to": "ISS", + "language": "0", + "offset": offsetHours, + "brandIndicator": "H", + "client_id": ClientID, + "clientSecret": ClientSecret, + "refresh": "false", + } +} + +type Identity struct { + *request.Helper + user, password string + oauth2.TokenSource +} + +func NewIdentity(log *util.Logger, user, password string) *Identity { + return &Identity{ + Helper: request.NewHelper(log), + user: user, + password: password, + } +} + +func (v *Identity) Login() error { + if v.user == "" || v.password == "" { + return api.ErrMissingCredentials + } + + token, err := v.login() + if err != nil { + return fmt.Errorf("login failed: %w", err) + } + + v.TokenSource = oauth.RefreshTokenSource(token, v) + return nil +} + +func (v *Identity) login() (*oauth2.Token, error) { + data := LoginRequest{ + Username: v.user, + Password: v.password, + } + + req, err := request.New(http.MethodPost, BaseURL+TokenURL, request.MarshalJSON(data), BaseHeaders()) + if err != nil { + return nil, err + } + + var res TokenResponse + if err := v.DoJSON(req, &res); err != nil { + return nil, err + } + + // Parse expires_in from string + expiresIn, _ := strconv.Atoi(res.ExpiresIn) + if expiresIn == 0 { + expiresIn = 3600 // default to 1 hour + } + + return &oauth2.Token{ + AccessToken: res.AccessToken, + RefreshToken: res.RefreshToken, + Expiry: time.Now().Add(time.Duration(expiresIn) * time.Second), + }, nil +} + +// RefreshToken implements oauth.TokenRefresher +// US API doesn't have a refresh endpoint, so we re-login with credentials +func (v *Identity) RefreshToken(_ *oauth2.Token) (*oauth2.Token, error) { + return v.login() +} diff --git a/vehicle/bluelink_us/provider.go b/vehicle/bluelink_us/provider.go new file mode 100644 index 000000000..2fc13de4c --- /dev/null +++ b/vehicle/bluelink_us/provider.go @@ -0,0 +1,120 @@ +package bluelink_us + +import ( + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" +) + +type Provider struct { + statusG func() (VehicleStatus, error) + positionG func() (PositionResponse, error) + chargeS func(bool) error + wakeup func() (VehicleStatus, error) +} + +func NewProvider(api *API, cache time.Duration) *Provider { + v := &Provider{ + wakeup: api.Status, + chargeS: func(enable bool) error { + if enable { + return api.ChargeStart() + } + return api.ChargeStop() + }, + } + + v.statusG = util.Cached(func() (VehicleStatus, error) { + return api.Status() + }, cache) + + v.positionG = util.Cached(func() (PositionResponse, error) { + return api.Position() + }, cache) + + return v +} + +var _ api.Battery = (*Provider)(nil) + +func (v *Provider) Soc() (float64, error) { + res, err := v.statusG() + if err != nil { + return 0, err + } + return res.SoC() +} + +var _ api.ChargeState = (*Provider)(nil) + +func (v *Provider) Status() (api.ChargeStatus, error) { + res, err := v.statusG() + if err != nil { + return api.StatusNone, err + } + return res.Status() +} + +var _ api.VehicleFinishTimer = (*Provider)(nil) + +func (v *Provider) FinishTime() (time.Time, error) { + res, err := v.statusG() + if err != nil { + return time.Time{}, err + } + return res.FinishTime() +} + +var _ api.VehicleRange = (*Provider)(nil) + +func (v *Provider) Range() (int64, error) { + res, err := v.statusG() + if err != nil { + return 0, err + } + return res.Range() +} + +var _ api.VehicleClimater = (*Provider)(nil) + +func (v *Provider) Climater() (bool, error) { + res, err := v.statusG() + if err != nil { + return false, err + } + return res.Climater() +} + +var _ api.SocLimiter = (*Provider)(nil) + +func (v *Provider) GetLimitSoc() (int64, error) { + res, err := v.statusG() + if err != nil { + return 0, err + } + return res.GetLimitSoc() +} + +var _ api.VehiclePosition = (*Provider)(nil) + +func (v *Provider) Position() (float64, float64, error) { + res, err := v.positionG() + if err != nil { + return 0, 0, err + } + return res.Coord.Lat, res.Coord.Lon, nil +} + +var _ api.ChargeController = (*Provider)(nil) + +func (v *Provider) ChargeEnable(enable bool) error { + return v.chargeS(enable) +} + +var _ api.Resurrector = (*Provider)(nil) + +func (v *Provider) WakeUp() error { + _, err := v.wakeup() + return err +} diff --git a/vehicle/bluelink_us/types.go b/vehicle/bluelink_us/types.go new file mode 100644 index 000000000..d39635567 --- /dev/null +++ b/vehicle/bluelink_us/types.go @@ -0,0 +1,210 @@ +package bluelink_us + +import ( + "time" + + "github.com/evcc-io/evcc/api" +) + +// Authentication types + +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type TokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn string `json:"expires_in"` + TokenType string `json:"token_type"` +} + +// Vehicle types + +type Vehicle struct { + RegID string `json:"regid"` + VIN string `json:"vin"` + NickName string `json:"nickName"` + ModelCode string `json:"modelCode"` + VehicleGeneration string `json:"vehicleGeneration"` + EvStatus string `json:"evStatus"` // "E" = EV, "N" = ICE + EnrollmentStatus string `json:"enrollmentStatus"` + EnrollmentDate string `json:"enrollmentDate"` +} + +type EnrollmentResponse struct { + EnrolledVehicleDetails []struct { + VehicleDetails Vehicle `json:"vehicleDetails"` + } `json:"enrolledVehicleDetails"` +} + +// Vehicle status types + +type VehicleStatusResponse struct { + VehicleStatus VehicleStatus `json:"vehicleStatus"` +} + +type VehicleStatus struct { + DateTime string `json:"dateTime"` + EvStatus *EvStatus `json:"evStatus,omitempty"` + DTE *DTE `json:"dte,omitempty"` // distance to empty + AirCtrlOn bool `json:"airCtrlOn"` + Defrost bool `json:"defrost"` + Engine bool `json:"engine"` + DoorLock bool `json:"doorLock"` +} + +type EvStatus struct { + BatteryStatus float64 `json:"batteryStatus"` + BatteryCharge bool `json:"batteryCharge"` + BatteryPlugin int `json:"batteryPlugin"` + ChargePortDoorOpenStatus int `json:"chargePortDoorOpenStatus"` + BatteryStndChrgPower float64 `json:"batteryStndChrgPower,omitempty"` + DrvDistance []DrvDistance `json:"drvDistance,omitempty"` + RemainTime2 *RemainTime `json:"remainTime2,omitempty"` + ReservChargeInfos *ReservChargeInfo `json:"reservChargeInfos,omitempty"` +} + +type DrvDistance struct { + RangeByFuel RangeByFuel `json:"rangeByFuel"` +} + +type RangeByFuel struct { + EvModeRange *RangeValue `json:"evModeRange,omitempty"` + GasModeRange *RangeValue `json:"gasModeRange,omitempty"` + TotalAvailableRange *RangeValue `json:"totalAvailableRange,omitempty"` +} + +type RangeValue struct { + Value float64 `json:"value"` + Unit int `json:"unit"` // 1 = km, 3 = miles +} + +// RemainTime contains charging time estimates +type RemainTime struct { + Atc TimeValue `json:"atc"` // current charging method + Etc1 TimeValue `json:"etc1"` // fast charging + Etc2 TimeValue `json:"etc2"` // portable charging + Etc3 TimeValue `json:"etc3"` // station charging +} + +type TimeValue struct { + Value int `json:"value"` // minutes + Unit int `json:"unit,omitempty"` +} + +type ReservChargeInfo struct { + TargetSOCList []TargetSOC `json:"targetSOClist"` +} + +type TargetSOC struct { + PlugType int `json:"plugType"` // 0 = DC, 1 = AC + TargetSOCLevel int `json:"targetSOClevel"` // charge limit percentage +} + +// DTE is distance to empty +type DTE struct { + Value int `json:"value"` + Unit int `json:"unit"` // 1 = km, 3 = miles +} + +// Position types + +type PositionResponse struct { + Coord struct { + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + Alt float64 `json:"alt,omitempty"` + } `json:"coord"` + Time string `json:"time,omitempty"` +} + +const ( + plugTypeAC = 1 + unitMiles = 3 + kmPerMile = 1.60934 + timeFormatISO = "2006-01-02T15:04:05Z" +) + +// Interface method implementations on VehicleStatus + +func (v VehicleStatus) Updated() (time.Time, error) { + if t, err := time.Parse(timeFormatISO, v.DateTime); err == nil { + return t, nil + } + return time.Time{}, api.ErrNotAvailable +} + +func (v VehicleStatus) SoC() (float64, error) { + if v.EvStatus != nil { + return v.EvStatus.BatteryStatus, nil + } + return 0, api.ErrNotAvailable +} + +func (v VehicleStatus) Status() (api.ChargeStatus, error) { + if v.EvStatus != nil { + status := api.StatusA // disconnected + if v.EvStatus.BatteryPlugin > 0 || v.EvStatus.ChargePortDoorOpenStatus == 1 { + status = api.StatusB // connected, not charging + } + if v.EvStatus.BatteryCharge { + status = api.StatusC // charging + } + return status, nil + } + return api.StatusNone, api.ErrNotAvailable +} + +func (v VehicleStatus) FinishTime() (time.Time, error) { + if v.EvStatus != nil && v.EvStatus.RemainTime2 != nil { + remaining := v.EvStatus.RemainTime2.Atc.Value + if remaining > 0 { + ts, err := v.Updated() + if err != nil { + ts = time.Now() + } + return ts.Add(time.Duration(remaining) * time.Minute), nil + } + } + return time.Time{}, api.ErrNotAvailable +} + +func (v VehicleStatus) Range() (int64, error) { + if v.EvStatus != nil && len(v.EvStatus.DrvDistance) > 0 { + if evRange := v.EvStatus.DrvDistance[0].RangeByFuel.EvModeRange; evRange != nil { + value := evRange.Value + if evRange.Unit == unitMiles { + value *= kmPerMile + } + return int64(value + 0.5), nil + } + } + + // Fallback to DTE for hybrids + if v.DTE != nil && v.DTE.Value > 0 { + value := float64(v.DTE.Value) + if v.DTE.Unit == unitMiles { + value *= kmPerMile + } + return int64(value + 0.5), nil + } + + return 0, api.ErrNotAvailable +} + +func (v VehicleStatus) Climater() (bool, error) { + return v.AirCtrlOn || v.Defrost, nil +} + +func (v VehicleStatus) GetLimitSoc() (int64, error) { + if v.EvStatus != nil && v.EvStatus.ReservChargeInfos != nil { + for _, target := range v.EvStatus.ReservChargeInfos.TargetSOCList { + if target.PlugType == plugTypeAC { + return int64(target.TargetSOCLevel), nil + } + } + } + return 0, api.ErrNotAvailable +}