Always enable charging when climate request active (#381)

This commit is contained in:
andig 2020-10-14 18:28:00 +02:00 • committed by GitHub
parent 8e61200216
commit 43402b3661
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 196 additions and 91 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

66
vehicle/vw/impl.go Normal file
View file

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