Add Hyundai Bluelink (United States) (#26047)

This commit is contained in:
Marcus Better 2025-12-20 05:23:17 -05:00 • committed by GitHub
parent 1506de71c8
commit 14b4f7fe3e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 677 additions and 0 deletions

View file

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

90
vehicle/bluelink_us.go Normal file
View file

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

143
vehicle/bluelink_us/api.go Normal file
View file

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

View file

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

View file

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

View file

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