diff --git a/api/api.go b/api/api.go index 4f87bd32b..c5f441711 100644 --- a/api/api.go +++ b/api/api.go @@ -91,3 +91,8 @@ type Vehicle interface { type ChargeFinishTimer interface { FinishTime() (time.Time, error) } + +// Climater provides climatisation data +type Climater interface { + Climater() (active bool, outsideTemp float64, targetTemp float64, err error) +} diff --git a/cmd/dump.go b/cmd/dump.go index 7bd958674..29e1fae6f 100644 --- a/cmd/dump.go +++ b/cmd/dump.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "math" "time" "github.com/andig/evcc/api" @@ -66,6 +67,20 @@ func dumpAPIs(v interface{}) { } } + if v, ok := v.(api.Climater); ok { + if active, ot, tt, err := v.Climater(); err != nil { + fmt.Printf("Climater: %v\n", err) + } else { + fmt.Printf("Climate active: %v\n", active) + if !math.IsNaN(ot) { + fmt.Printf("Outside temp: %.1f°C\n", ot) + } + if !math.IsNaN(tt) { + fmt.Printf("Target temp: %.1f°C\n", tt) + } + } + } + if v, ok := v.(api.Diagnosis); ok { fmt.Println("Diagnostic dump:") v.Diagnosis() diff --git a/core/loadpoint.go b/core/loadpoint.go index d2a145db4..74ce8f5d3 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -382,6 +382,21 @@ func (lp *LoadPoint) targetSocReached(socCharge, targetSoC float64) bool { return targetSoC > 0 && targetSoC < 100 && socCharge >= targetSoC } +// climateActive checks if vehicle has active climate request +func (lp *LoadPoint) climateActive() bool { + if cl, ok := lp.vehicle.(api.Climater); ok { + active, outsideTemp, targetTemp, err := cl.Climater() + if err == nil { + lp.log.DEBUG.Printf("climater active: %v, target temp: %.1f°C, outside temp: %.1f°C", active, targetTemp, outsideTemp) + return active + } + + lp.log.ERROR.Printf("climater: %v", err) + } + + return false +} + // updateChargerStatus updates car status and detects car connected/disconnected events func (lp *LoadPoint) updateChargerStatus() error { status, err := lp.handler.Status() @@ -656,7 +671,11 @@ func (lp *LoadPoint) Update(sitePower float64) { err = lp.handler.Ramp(0) case lp.targetSocReached(lp.socCharge, float64(lp.TargetSoC)): - err = lp.handler.Ramp(0) + var targetCurrent int64 + if lp.climateActive() { + targetCurrent = lp.MinCurrent + } + err = lp.handler.Ramp(targetCurrent, true) case mode == api.ModeOff: err = lp.handler.Ramp(0, true) @@ -668,7 +687,13 @@ func (lp *LoadPoint) Update(sitePower float64) { targetCurrent := lp.maxCurrent(mode, sitePower) lp.log.DEBUG.Printf("target charge current: %dA", targetCurrent) - err = lp.handler.Ramp(targetCurrent) + var required bool // false + if targetCurrent == 0 && lp.climateActive() { + targetCurrent = lp.MinCurrent + required = true + } + + err = lp.handler.Ramp(targetCurrent, required) } if err != nil { diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go index 72cd1890f..849581e9c 100644 --- a/core/loadpoint_test.go +++ b/core/loadpoint_test.go @@ -111,10 +111,14 @@ func TestUpdate(t *testing.T) { h.EXPECT().Ramp(lpMaxCurrent, true) }}, {api.StatusB, api.ModeMinPV, func(h *mock.MockHandler) { - h.EXPECT().Ramp(lpMinCurrent) // min since update called with 0 + // min since update called with 0 + // force = false due to pv mode climater check + h.EXPECT().Ramp(lpMinCurrent, false) }}, {api.StatusB, api.ModePV, func(h *mock.MockHandler) { - h.EXPECT().Ramp(int64(0)) // zero since update called with 0 + // zero since update called with 0 + // force = false due to pv mode climater check + h.EXPECT().Ramp(int64(0), false) // h.EXPECT().Enabled().Return(false) // short-circuited due to status != C }}, @@ -125,10 +129,14 @@ func TestUpdate(t *testing.T) { h.EXPECT().Ramp(lpMaxCurrent, true) }}, {api.StatusC, api.ModeMinPV, func(h *mock.MockHandler) { - h.EXPECT().Ramp(lpMinCurrent) // min since update called with 0 + // min since update called with 0 + // force = false due to pv mode climater check + h.EXPECT().Ramp(lpMinCurrent, false) }}, {api.StatusC, api.ModePV, func(h *mock.MockHandler) { - h.EXPECT().Ramp(int64(0)) // zero since update called with 0 + // zero since update called with 0 + // force = false due to pv mode climater check + h.EXPECT().Ramp(int64(0), false) h.EXPECT().Enabled().Return(false) }}, } @@ -412,7 +420,7 @@ func TestDisableAndEnableAtTargetSoC(t *testing.T) { handler.EXPECT().Status().Return(api.StatusC, nil) vehicle.EXPECT().ChargeState().Return(90.0, nil) handler.EXPECT().SyncEnabled().Return() - handler.EXPECT().Ramp(int64(0)).Return(nil) + handler.EXPECT().Ramp(int64(0), true).Return(nil) // true due to immediately handling climate requests lp.Update(500) // deactivated charger changes status to B @@ -422,7 +430,7 @@ func TestDisableAndEnableAtTargetSoC(t *testing.T) { handler.EXPECT().TargetCurrent().Return(int64(0)) // once more for status changes vehicle.EXPECT().ChargeState().Return(95.0, nil) handler.EXPECT().SyncEnabled().Return() - handler.EXPECT().Ramp(int64(0)).Return(nil) + handler.EXPECT().Ramp(int64(0), true).Return(nil) // true due to immediately handling climate requests lp.Update(-5000) // soc has fallen below target diff --git a/vehicle/audi.go b/vehicle/audi.go index 0b68242ac..1b5f0387e 100644 --- a/vehicle/audi.go +++ b/vehicle/audi.go @@ -9,7 +9,6 @@ import ( "time" "github.com/andig/evcc/api" - "github.com/andig/evcc/provider" "github.com/andig/evcc/util" "github.com/andig/evcc/util/request" "github.com/andig/evcc/vehicle/vw" @@ -22,10 +21,10 @@ import ( type Audi struct { *embed *request.Helper - user, password string - tokens vw.Tokens - api *vw.API - apiG func() (interface{}, error) + user, password string + tokens vw.Tokens + api *vw.API + *vw.Implementation // provides the api implementations } func init() { @@ -56,7 +55,7 @@ func NewAudiFromConfig(other map[string]interface{}) (api.Vehicle, error) { } v.api = vw.NewAPI(v.Helper, &v.tokens, v.authFlow, v.refreshHeaders, strings.ToUpper(cc.VIN), "Audi", "DE") - v.apiG = provider.NewCached(v.apiCall, cc.Cache).InterfaceGetter() + v.Implementation = vw.NewImplementation(v.api, cc.Cache) var err error jar, err := cookiejar.New(&cookiejar.Options{ @@ -162,34 +161,3 @@ func (v *Audi) refreshHeaders() map[string]string { "X-Client-Id": audiClientID, } } - -// apiCall provides charger api response -func (v *Audi) apiCall() (interface{}, error) { - res, err := v.api.Charger() - return res, err -} - -// ChargeState implements the Vehicle.ChargeState interface -func (v *Audi) ChargeState() (float64, error) { - res, err := v.apiG() - if res, ok := res.(vw.ChargerResponse); err == nil && ok { - return float64(res.Charger.Status.BatteryStatusData.StateOfCharge.Content), nil - } - - return 0, err -} - -// FinishTime implements the Vehicle.ChargeFinishTimer interface -func (v *Audi) FinishTime() (time.Time, error) { - res, err := v.apiG() - if res, ok := res.(vw.ChargerResponse); err == nil && ok { - var timestamp time.Time - if err == nil { - timestamp, err = time.Parse(time.RFC3339, res.Charger.Status.BatteryStatusData.RemainingChargingTime.Timestamp) - } - - return timestamp.Add(time.Duration(res.Charger.Status.BatteryStatusData.RemainingChargingTime.Content) * time.Minute), err - } - - return time.Time{}, err -} diff --git a/vehicle/vw.go b/vehicle/vw.go index fd3bd252d..e20089b42 100644 --- a/vehicle/vw.go +++ b/vehicle/vw.go @@ -9,7 +9,6 @@ import ( "time" "github.com/andig/evcc/api" - "github.com/andig/evcc/provider" "github.com/andig/evcc/util" "github.com/andig/evcc/util/request" "github.com/andig/evcc/vehicle/vw" @@ -22,11 +21,11 @@ import ( type VW struct { *embed *request.Helper - user, password string - clientID string - tokens vw.Tokens - api *vw.API - apiG func() (interface{}, error) + user, password string + clientID string + tokens vw.Tokens + api *vw.API + *vw.Implementation // provides the api implementations } func init() { @@ -55,7 +54,7 @@ func NewVWFromConfig(other map[string]interface{}) (api.Vehicle, error) { } v.api = vw.NewAPI(v.Helper, &v.tokens, v.authFlow, v.refreshHeaders, strings.ToUpper(cc.VIN), "VW", "DE") - v.apiG = provider.NewCached(v.apiCall, cc.Cache).InterfaceGetter() + v.Implementation = vw.NewImplementation(v.api, cc.Cache) var err error jar, err := cookiejar.New(&cookiejar.Options{ @@ -200,34 +199,3 @@ func (v *VW) refreshHeaders() map[string]string { "X-Client-Id": v.clientID, } } - -// apiCall provides charger api response -func (v *VW) apiCall() (interface{}, error) { - res, err := v.api.Charger() - return res, err -} - -// ChargeState implements the Vehicle.ChargeState interface -func (v *VW) ChargeState() (float64, error) { - res, err := v.apiG() - if res, ok := res.(vw.ChargerResponse); err == nil && ok { - return float64(res.Charger.Status.BatteryStatusData.StateOfCharge.Content), nil - } - - return 0, err -} - -// FinishTime implements the Vehicle.ChargeFinishTimer interface -func (v *VW) FinishTime() (time.Time, error) { - res, err := v.apiG() - if res, ok := res.(vw.ChargerResponse); err == nil && ok { - var timestamp time.Time - if err == nil { - timestamp, err = time.Parse(time.RFC3339, res.Charger.Status.BatteryStatusData.RemainingChargingTime.Timestamp) - } - - return timestamp.Add(time.Duration(res.Charger.Status.BatteryStatusData.RemainingChargingTime.Content) * time.Minute), err - } - - return time.Time{}, err -} diff --git a/vehicle/vw/api.go b/vehicle/vw/api.go index a245075b4..81dd3c730 100644 --- a/vehicle/vw/api.go +++ b/vehicle/vw/api.go @@ -20,24 +20,58 @@ type VehiclesResponse struct { } } +// TimedInt is an int value with timestamp +type TimedInt struct { + Content int + Timestamp string +} + +// TimedString is a string value with timestamp +type TimedString struct { + Content string + Timestamp string +} + // ChargerResponse is the /bs/batterycharge/v1/%s/%s/vehicles/%s/charger api type ChargerResponse struct { Charger struct { Status struct { BatteryStatusData struct { - StateOfCharge struct { - Content int - Timestamp string - } - RemainingChargingTime struct { - Content int - Timestamp string - } + StateOfCharge TimedInt + RemainingChargingTime TimedInt } } } } +// ClimaterResponse is the /bs/climatisation/v1/%s/%s/vehicles/%s/climater api +type ClimaterResponse struct { + Climater struct { + Settings struct { + TargetTemperature TimedInt + HeaterSource TimedString + } + Status struct { + ClimatisationStatusData struct { + ClimatisationState TimedString + ClimatisationReason TimedString + RemainingClimatisationTime TimedInt + } + TemperatureStatusData struct { + OutdoorTemperature TimedInt + } + VehicleParkingClockStatusData struct { + VehicleParkingClock TimedString + } + } + } +} + +// Temp2Float converts api temp to float value +func Temp2Float(val int) float64 { + return float64(val)/10 - 273 +} + // API is the VW api client type API struct { *request.Helper @@ -140,3 +174,19 @@ func (v *API) Charger() (ChargerResponse, error) { err := v.getJSON(uri, &res) return res, err } + +// Climater implements the /climater response +func (v *API) Climater() (ClimaterResponse, error) { + var res ClimaterResponse + uri := fmt.Sprintf("%s/bs/climatisation/v1/%s/%s/vehicles/%s/climater", BaseURI, v.brand, v.country, v.VIN) + err := v.getJSON(uri, &res) + return res, err +} + +// Any implements any api response +func (v *API) Any(base string) (interface{}, error) { + var res interface{} + uri := fmt.Sprintf("%s/"+strings.TrimLeft(base, "/"), BaseURI, v.brand, v.country, v.VIN) + err := v.getJSON(uri, &res) + return res, err +} diff --git a/vehicle/vw/impl.go b/vehicle/vw/impl.go new file mode 100644 index 000000000..399e49ce0 --- /dev/null +++ b/vehicle/vw/impl.go @@ -0,0 +1,66 @@ +package vw + +import ( + "strings" + "time" + + "github.com/andig/evcc/provider" +) + +// Implementation implements the evcc vehicle api +type Implementation struct { + chargerG func() (interface{}, error) + climateG func() (interface{}, error) +} + +// NewImplementation provides the evcc vehicle api implementation +func NewImplementation(api *API, cache time.Duration) *Implementation { + impl := &Implementation{ + chargerG: provider.NewCached(func() (interface{}, error) { + return api.Charger() + }, cache).InterfaceGetter(), + climateG: provider.NewCached(func() (interface{}, error) { + return api.Climater() + }, cache).InterfaceGetter(), + } + return impl +} + +// ChargeState implements the Vehicle.ChargeState interface +func (v *Implementation) ChargeState() (float64, error) { + res, err := v.chargerG() + if res, ok := res.(ChargerResponse); err == nil && ok { + return float64(res.Charger.Status.BatteryStatusData.StateOfCharge.Content), nil + } + + return 0, err +} + +// FinishTime implements the Vehicle.ChargeFinishTimer interface +func (v *Implementation) FinishTime() (time.Time, error) { + res, err := v.chargerG() + if res, ok := res.(ChargerResponse); err == nil && ok { + var timestamp time.Time + if err == nil { + timestamp, err = time.Parse(time.RFC3339, res.Charger.Status.BatteryStatusData.RemainingChargingTime.Timestamp) + } + + return timestamp.Add(time.Duration(res.Charger.Status.BatteryStatusData.RemainingChargingTime.Content) * time.Minute), err + } + + return time.Time{}, err +} + +// Climater implements the Vehicle.Climater interface +func (v *Implementation) Climater() (active bool, outsideTemp float64, targetTemp float64, err error) { + res, err := v.climateG() + if res, ok := res.(ClimaterResponse); err == nil && ok { + active = "off" != strings.ToLower(res.Climater.Status.ClimatisationStatusData.ClimatisationState.Content) + outsideTemp = Temp2Float(res.Climater.Status.TemperatureStatusData.OutdoorTemperature.Content) + targetTemp = Temp2Float(res.Climater.Settings.TargetTemperature.Content) + + return active, outsideTemp, targetTemp, nil + } + + return active, outsideTemp, targetTemp, err +}