From bf584796c0b6434a348aaf906b5791f9ebebef64 Mon Sep 17 00:00:00 2001 From: Simon Schenk Date: Sat, 4 May 2024 11:00:36 +0200 Subject: [PATCH] Bluelink: add CCS api support (#13713) --- vehicle/bluelink.go | 2 +- vehicle/bluelink/api.go | 41 ++++-- vehicle/bluelink/provider.go | 108 ++++----------- vehicle/bluelink/types.go | 253 ++++++++++++++++++++++++++++++++++- 4 files changed, 310 insertions(+), 94 deletions(-) diff --git a/vehicle/bluelink.go b/vehicle/bluelink.go index 2883e4c92..a85d9998f 100644 --- a/vehicle/bluelink.go +++ b/vehicle/bluelink.go @@ -94,7 +94,7 @@ func newBluelinkFromConfig(brand string, other map[string]interface{}, settings v := &Bluelink{ embed: &cc.embed, - Provider: bluelink.NewProvider(api, vehicle.VehicleID, cc.Expiry, cc.Cache), + Provider: bluelink.NewProvider(api, vehicle, cc.Expiry, cc.Cache), } return v, nil diff --git a/vehicle/bluelink/api.go b/vehicle/bluelink/api.go index 5d9b102f3..870708ea8 100644 --- a/vehicle/bluelink/api.go +++ b/vehicle/bluelink/api.go @@ -13,9 +13,11 @@ import ( ) const ( - VehiclesURL = "vehicles" - StatusURL = "vehicles/%s/status" - StatusLatestURL = "vehicles/%s/status/latest" + VehiclesURL = "vehicles" + StatusURL = "vehicles/%s/status" + StatusLatestURL = "vehicles/%s/status/latest" + StatusURLCCS2 = "vehicles/%s/ccs2/carstatus" + StatusLatestURLCCS2 = "vehicles/%s/ccs2/carstatus/latest" ) const ( @@ -51,6 +53,7 @@ func NewAPI(log *util.Logger, baseURI string, decorator func(*http.Request) erro type Vehicle struct { VIN, VehicleName, VehicleID string + CcuCCS2ProtocolSupport int } func (v *API) Vehicles() ([]Vehicle, error) { @@ -63,27 +66,47 @@ func (v *API) Vehicles() ([]Vehicle, error) { } // StatusLatest retrieves the latest server-side status -func (v *API) StatusLatest(vid string) (StatusLatestResponse, error) { - var res StatusLatestResponse +func (v *API) StatusLatest(vehicle Vehicle) (BluelinkVehicleStatusLatest, error) { + vid := vehicle.VehicleID + if vehicle.CcuCCS2ProtocolSupport != 0 { + var res StatusLatestResponseCCS + uri := fmt.Sprintf("%s/%s", v.baseURI, fmt.Sprintf(StatusLatestURLCCS2, vid)) + err := v.GetJSON(uri, &res) + if err == nil && res.RetCode != resOK { + err = fmt.Errorf("unexpected response: %s", res.RetCode) + } + return res, err + } + + var res StatusLatestResponse uri := fmt.Sprintf("%s/%s", v.baseURI, fmt.Sprintf(StatusLatestURL, vid)) err := v.GetJSON(uri, &res) if err == nil && res.RetCode != resOK { err = fmt.Errorf("unexpected response: %s", res.RetCode) } - return res, err } // StatusPartial refreshes the status -func (v *API) StatusPartial(vid string) (StatusResponse, error) { - var res StatusResponse +func (v *API) StatusPartial(vehicle Vehicle) (BluelinkVehicleStatus, error) { + vid := vehicle.VehicleID + if vehicle.CcuCCS2ProtocolSupport != 0 { + var res StatusLatestResponseCCS + uri := fmt.Sprintf("%s/%s", v.baseURI, fmt.Sprintf(StatusLatestURLCCS2, vid)) + err := v.GetJSON(uri, &res) + if err == nil && res.RetCode != resOK { + err = fmt.Errorf("unexpected response: %s", res.RetCode) + } + return res, err + } + + var res StatusResponse uri := fmt.Sprintf("%s/%s", v.baseURI, fmt.Sprintf(StatusURL, vid)) err := v.GetJSON(uri, &res) if err == nil && res.RetCode != resOK { err = fmt.Errorf("unexpected response: %s", res.RetCode) } - return res, err } diff --git a/vehicle/bluelink/provider.go b/vehicle/bluelink/provider.go index ad7d023aa..86a526f50 100644 --- a/vehicle/bluelink/provider.go +++ b/vehicle/bluelink/provider.go @@ -12,50 +12,50 @@ const refreshTimeout = 2 * time.Minute // Provider implements the vehicle api. // Based on https://github.com/Hacksore/bluelinky. type Provider struct { - statusG func() (VehicleStatus, error) - statusLG func() (StatusLatestResponse, error) - refreshG func() (StatusResponse, error) + statusG func() (BluelinkVehicleStatus, error) + statusLG func() (BluelinkVehicleStatusLatest, error) + refreshG func() (BluelinkVehicleStatus, error) expiry time.Duration refreshTime time.Time } // New creates a new BlueLink API -func NewProvider(api *API, vid string, expiry, cache time.Duration) *Provider { +func NewProvider(api *API, vehicle Vehicle, expiry, cache time.Duration) *Provider { v := &Provider{ - refreshG: func() (StatusResponse, error) { - return api.StatusPartial(vid) + refreshG: func() (BluelinkVehicleStatus, error) { + return api.StatusPartial(vehicle) }, expiry: expiry, } - v.statusG = provider.Cached(func() (VehicleStatus, error) { + v.statusG = provider.Cached(func() (BluelinkVehicleStatus, error) { return v.status( - func() (StatusLatestResponse, error) { return api.StatusLatest(vid) }, + func() (BluelinkVehicleStatus, error) { return api.StatusPartial(vehicle) }, ) }, cache) - v.statusLG = provider.Cached(func() (StatusLatestResponse, error) { - return api.StatusLatest(vid) + v.statusLG = provider.Cached(func() (BluelinkVehicleStatusLatest, error) { + return api.StatusLatest(vehicle) }, cache) return v } // status wraps the api status call and adds status refresh -func (v *Provider) status(statusG func() (StatusLatestResponse, error)) (VehicleStatus, error) { +func (v *Provider) status(statusG func() (BluelinkVehicleStatus, error)) (BluelinkVehicleStatus, error) { res, err := statusG() var ts time.Time if err == nil { - ts, err = res.ResMsg.VehicleStatusInfo.VehicleStatus.Updated() + ts, err = res.Updated() if err != nil { - return res.ResMsg.VehicleStatusInfo.VehicleStatus, err + return res, err } // return the current value if time.Since(ts) <= v.expiry { v.refreshTime = time.Time{} - return res.ResMsg.VehicleStatusInfo.VehicleStatus, nil + return res, nil } } @@ -66,15 +66,15 @@ func (v *Provider) status(statusG func() (StatusLatestResponse, error)) (Vehicle // TODO async refresh res, err := v.refreshG() if err == nil { - if ts, err = res.ResMsg.Updated(); err == nil && time.Since(ts) <= v.expiry { + if ts, err = res.Updated(); err == nil && time.Since(ts) <= v.expiry { v.refreshTime = time.Time{} - return res.ResMsg, nil + return res, nil } err = api.ErrMustRetry } - return VehicleStatus{}, err + return nil, err } // refresh finally expired @@ -88,7 +88,7 @@ func (v *Provider) status(statusG func() (StatusLatestResponse, error)) (Vehicle err = api.ErrMustRetry } - return VehicleStatus{}, err + return nil, err } var _ api.Battery = (*Provider)(nil) @@ -99,12 +99,7 @@ func (v *Provider) Soc() (float64, error) { if err != nil { return 0, err } - - if res.EvStatus != nil { - return res.EvStatus.BatteryStatus, nil - } - - return 0, api.ErrNotAvailable + return res.SoC() } var _ api.ChargeState = (*Provider)(nil) @@ -112,25 +107,11 @@ var _ api.ChargeState = (*Provider)(nil) // Status implements the api.Battery interface func (v *Provider) Status() (api.ChargeStatus, error) { status := api.StatusNone - res, err := v.statusG() if err != nil { return status, err } - - if res.EvStatus != nil { - status = api.StatusA - if res.EvStatus.BatteryPlugin > 0 || res.EvStatus.ChargePortDoorOpenStatus == 1 { - status = api.StatusB - } - if res.EvStatus.BatteryCharge { - status = api.StatusC - } - } else { - err = api.ErrNotAvailable - } - - return status, err + return res.Status() } var _ api.VehicleFinishTimer = (*Provider)(nil) @@ -141,19 +122,7 @@ func (v *Provider) FinishTime() (time.Time, error) { if err != nil { return time.Time{}, err } - - if res.EvStatus != nil { - remaining := res.EvStatus.RemainTime2.Atc.Value - - if remaining == 0 { - return time.Time{}, api.ErrNotAvailable - } - - ts, err := res.Updated() - return ts.Add(time.Duration(remaining) * time.Minute), err - } - - return time.Time{}, api.ErrNotAvailable + return res.FinishTime() } var _ api.VehicleRange = (*Provider)(nil) @@ -164,14 +133,7 @@ func (v *Provider) Range() (int64, error) { if err != nil { return 0, err } - - if res.EvStatus != nil { - if dist := res.EvStatus.DrvDistance; len(dist) == 1 { - return int64(dist[0].RangeByFuel.EvModeRange.Value), nil - } - } - - return 0, api.ErrNotAvailable + return res.Range() } var _ api.VehicleOdometer = (*Provider)(nil) @@ -182,12 +144,7 @@ func (v *Provider) Odometer() (float64, error) { if err != nil { return 0, err } - - if res.ResMsg.VehicleStatusInfo.Odometer != nil { - return res.ResMsg.VehicleStatusInfo.Odometer.Value, err - } - - return 0, api.ErrNotAvailable + return res.Odometer() } var _ api.SocLimiter = (*Provider)(nil) @@ -198,16 +155,7 @@ func (v *Provider) GetLimitSoc() (int64, error) { if err != nil { return 0, err } - - if res.EvStatus != nil { - for _, targetSOC := range res.EvStatus.ReservChargeInfos.TargetSocList { - if targetSOC.PlugType == plugTypeAC { - return int64(targetSOC.TargetSocLevel), nil - } - } - } - - return 0, api.ErrNotAvailable + return res.GetLimitSoc() } var _ api.VehiclePosition = (*Provider)(nil) @@ -218,13 +166,7 @@ func (v *Provider) Position() (float64, float64, error) { if err != nil { return 0, 0, err } - - if res.ResMsg.VehicleStatusInfo.VehicleLocation != nil { - coord := res.ResMsg.VehicleStatusInfo.VehicleLocation.Coord - return coord.Lat, coord.Lon, err - } - - return 0, 0, api.ErrNotAvailable + return res.Position() } var _ api.Resurrector = (*Provider)(nil) diff --git a/vehicle/bluelink/types.go b/vehicle/bluelink/types.go index fd658cba0..13e557685 100644 --- a/vehicle/bluelink/types.go +++ b/vehicle/bluelink/types.go @@ -1,6 +1,25 @@ package bluelink -import "time" +import ( + "strconv" + "time" + + "github.com/evcc-io/evcc/api" +) + +type BluelinkVehicleStatus interface { + Updated() (time.Time, error) + SoC() (float64, error) + Status() (api.ChargeStatus, error) + FinishTime() (time.Time, error) + Range() (int64, error) + GetLimitSoc() (int64, error) +} + +type BluelinkVehicleStatusLatest interface { + Odometer() (float64, error) + Position() (float64, float64, error) +} type VehiclesResponse struct { RetCode string @@ -84,3 +103,235 @@ type TargetSoc struct { TargetSocLevel int PlugType int } + +func (d StatusResponse) Updated() (time.Time, error) { + return time.Parse(timeFormat, d.ResMsg.Time+timeOffset) +} + +func (d StatusResponse) SoC() (float64, error) { + if d.ResMsg.EvStatus != nil { + return d.ResMsg.EvStatus.BatteryStatus, nil + } + + return 0, api.ErrNotAvailable +} + +func (d StatusResponse) Status() (api.ChargeStatus, error) { + if d.ResMsg.EvStatus != nil { + status := api.StatusA + if d.ResMsg.EvStatus.BatteryPlugin > 0 || d.ResMsg.EvStatus.ChargePortDoorOpenStatus == 1 { + status = api.StatusB + } + if d.ResMsg.EvStatus.BatteryCharge { + status = api.StatusC + } + return status, nil + } + + return api.StatusNone, api.ErrNotAvailable +} + +func (d StatusResponse) FinishTime() (time.Time, error) { + if d.ResMsg.EvStatus != nil { + remaining := d.ResMsg.EvStatus.RemainTime2.Atc.Value + + if remaining != 0 { + ts, err := d.ResMsg.Updated() + return ts.Add(time.Duration(remaining) * time.Minute), err + } + } + + return time.Time{}, api.ErrNotAvailable +} + +func (d StatusResponse) Range() (int64, error) { + if d.ResMsg.EvStatus != nil { + if dist := d.ResMsg.EvStatus.DrvDistance; len(dist) == 1 { + return int64(dist[0].RangeByFuel.EvModeRange.Value), nil + } + } + return 0, api.ErrNotAvailable +} + +func (d StatusResponse) GetLimitSoc() (int64, error) { + if d.ResMsg.EvStatus != nil { + for _, targetSOC := range d.ResMsg.EvStatus.ReservChargeInfos.TargetSocList { + if targetSOC.PlugType == plugTypeAC { + return int64(targetSOC.TargetSocLevel), nil + } + } + } + return 0, api.ErrNotAvailable +} + +func (d StatusLatestResponse) Odometer() (float64, error) { + if d.ResMsg.VehicleStatusInfo.Odometer != nil { + return d.ResMsg.VehicleStatusInfo.Odometer.Value, nil + } + return 0, api.ErrNotAvailable +} + +func (d StatusLatestResponse) Position() (float64, float64, error) { + if d.ResMsg.VehicleStatusInfo.VehicleLocation != nil { + pos := d.ResMsg.VehicleStatusInfo.VehicleLocation.Coord + return pos.Lat, pos.Lon, nil + } + return 0, 0, api.ErrNotAvailable +} + +type StatusLatestResponseCCS struct { + RetCode string + ResCode string + ResMsg struct { + State struct { + Vehicle VehicleStatusCCS + } + LastUpdateTime string + } +} + +type VehicleStatusCCS struct { + Location *struct { + GeoCoord struct { + Latitude, Longitude, Altitude float64 + Type int + Date string + } + } + Green *struct { + BatteryManagement struct { + BatteryRemain struct { + Ratio float64 + Value float64 + } + BatteryCapacity struct { + Value float64 + } + SoH struct { + Ratio float64 + } + } + ChargingInformation struct { + ConnectorFastening struct { + // 1 connected + State int + } + Charging struct { + RemainTime float64 + RemainTimeUnit int + } + EstimatedTime struct { + Standard float64 + ICCB float64 + Quick float64 + Unit int + } + ExpectedTime struct { + StartDay int + StartHour int + StartMin int + EndDay int + EndHour int + EndMin int + } + TargetSoC struct { + Standard int64 + Quick int64 + } + DTE struct { + TargetSoC struct { + // in Drivetrain.FuelSystem.DTE.Unit + Standard float64 + Quick float64 + } + } + } + ChargingDoor struct { + // 0, 2 closed, 1 open + State int + } + Electric struct { + SmartGrid struct { + VehicleToLoad struct { + DischargeLimitation struct { + SoC float64 + RemainTime float64 + } + } + } + } + } + Drivetrain struct { + Odometer float64 + FuelSystem struct { + DTE struct { + Total int64 + } + } + } +} + +func (d StatusLatestResponseCCS) Updated() (time.Time, error) { + epoch, err := strconv.ParseInt(d.ResMsg.LastUpdateTime, 10, 64) + if err != nil { + return time.Now(), err + } + return time.UnixMilli(epoch), nil +} + +func (d StatusLatestResponseCCS) SoC() (float64, error) { + if d.ResMsg.State.Vehicle.Green != nil { + return d.ResMsg.State.Vehicle.Green.BatteryManagement.BatteryRemain.Ratio, nil + } + return 0, api.ErrNotAvailable +} + +func (d StatusLatestResponseCCS) Status() (api.ChargeStatus, error) { + if d.ResMsg.State.Vehicle.Green != nil { + if d.ResMsg.State.Vehicle.Green.ChargingInformation.ConnectorFastening.State == 1 { + return api.StatusB, nil + } + if d.ResMsg.State.Vehicle.Green.ChargingInformation.Charging.RemainTime > 0 { + return api.StatusC, nil + } + return api.StatusA, nil + } + return api.StatusNone, api.ErrNotAvailable +} + +func (d StatusLatestResponseCCS) FinishTime() (time.Time, error) { + if d.ResMsg.State.Vehicle.Green != nil { + remaining := d.ResMsg.State.Vehicle.Green.ChargingInformation.Charging.RemainTime + + if remaining == 0 { + return time.Time{}, api.ErrNotAvailable + } + + ts, err := d.Updated() + return ts.Add(time.Duration(remaining) * time.Minute), err + } + + return time.Now(), api.ErrNotAvailable +} + +func (d StatusLatestResponseCCS) Range() (int64, error) { + return d.ResMsg.State.Vehicle.Drivetrain.FuelSystem.DTE.Total, nil +} + +func (d StatusLatestResponseCCS) GetLimitSoc() (int64, error) { + if d.ResMsg.State.Vehicle.Green != nil { + return d.ResMsg.State.Vehicle.Green.ChargingInformation.TargetSoC.Standard, nil + } + return 0, api.ErrNotAvailable +} + +func (d StatusLatestResponseCCS) Odometer() (float64, error) { + return d.ResMsg.State.Vehicle.Drivetrain.Odometer, nil +} + +func (d StatusLatestResponseCCS) Position() (float64, float64, error) { + if d.ResMsg.State.Vehicle.Location != nil { + return d.ResMsg.State.Vehicle.Location.GeoCoord.Latitude, d.ResMsg.State.Vehicle.Location.GeoCoord.Longitude, nil + } + return 0, 0, api.ErrNotAvailable +}