Bluelink: add CCS api support (#13713)

This commit is contained in:
Simon Schenk 2024-05-04 11:00:36 +02:00 • committed by GitHub
parent 4109b4bf74
commit bf584796c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 310 additions and 94 deletions

View file

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

View file

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

View file

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

View file

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