diff --git a/core/keys/site.go b/core/keys/site.go index a6ec22e9c..de780b2a6 100644 --- a/core/keys/site.go +++ b/core/keys/site.go @@ -53,4 +53,7 @@ const ( BatteryMode = "batteryMode" BatteryPower = "batteryPower" BatterySoc = "batterySoc" + + // external battery control + BatteryModeExternal = "batteryModeExternal" ) diff --git a/core/site.go b/core/site.go index 4948766b6..37290e567 100644 --- a/core/site.go +++ b/core/site.go @@ -103,14 +103,16 @@ type Site struct { pvEnergy map[string]*meterEnergy // cached state - gridPower float64 // Grid power - pvPower float64 // PV power - excessDCPower float64 // PV excess DC charge power (hybrid only) - auxPower float64 // Aux power - batteryPower float64 // Battery power (charge negative, discharge positive) - batterySoc float64 // Battery soc - batteryCapacity float64 // Battery capacity - batteryMode api.BatteryMode // Battery mode (runtime only, not persisted) + gridPower float64 // Grid power + pvPower float64 // PV power + excessDCPower float64 // PV excess DC charge power (hybrid only) + auxPower float64 // Aux power + batteryPower float64 // Battery power (charge negative, discharge positive) + batterySoc float64 // Battery soc + batteryCapacity float64 // Battery capacity + batteryMode api.BatteryMode // Battery mode (runtime only, not persisted) + batteryModeExternal api.BatteryMode // Battery mode (external, runtime only, not persisted) + batteryModeExternalTimer time.Time // Battery mode timer for external control } // MetersConfig contains the site's meter configuration diff --git a/core/site/api.go b/core/site/api.go index 10c52ac61..a514a092d 100644 --- a/core/site/api.go +++ b/core/site/api.go @@ -67,4 +67,13 @@ type API interface { GetBatteryDischargeControl() bool SetBatteryDischargeControl(bool) error + + // + // battery control external + // + + // GetBatteryModeExternal returns the external battery mode + GetBatteryModeExternal() api.BatteryMode + // SetBatteryModeExternal sets the external battery mode + SetBatteryModeExternal(api.BatteryMode) } diff --git a/core/site_api.go b/core/site_api.go index da033413f..d10cd92cb 100644 --- a/core/site_api.go +++ b/core/site_api.go @@ -3,6 +3,7 @@ package core import ( "errors" "strings" + "time" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/core/keys" @@ -335,3 +336,65 @@ func (site *Site) SetBatteryGridChargeLimit(val *float64) { } } } + +// GetBatteryMode returns the battery mode +func (site *Site) GetBatteryMode() api.BatteryMode { + site.RLock() + defer site.RUnlock() + return site.batteryMode +} + +// GetBatteryModeExternal returns the external battery mode +func (site *Site) GetBatteryModeExternal() api.BatteryMode { + site.RLock() + defer site.RUnlock() + return site.batteryModeExternal +} + +// SetBatteryModeExternal sets the external battery mode +func (site *Site) SetBatteryModeExternal(mode api.BatteryMode) { + site.Lock() + defer site.Unlock() + + site.log.DEBUG.Printf("set external battery mode: %s", mode.String()) + + disable := mode == api.BatteryUnknown + + if mode != site.batteryModeExternal { + site.batteryModeExternal = mode + site.publish(keys.BatteryModeExternal, mode) + + if !disable { + site.setBatteryMode(mode) + + // start watchdog if not running + if site.batteryModeExternalTimer.IsZero() { + go func() { + for range time.Tick(time.Second) { + if site.batteryModeWatchdogExpired() { + return + } + } + }() + } + } + } + + // reset timer + if !disable { + site.batteryModeExternalTimer = time.Now() + } +} + +func (site *Site) batteryModeWatchdogExpired() bool { + site.RLock() + elapsed := time.Since(site.batteryModeExternalTimer) + site.RUnlock() + + if elapsed > time.Minute && !site.batteryModeExternalTimer.IsZero() { + site.SetBatteryModeExternal(api.BatteryUnknown) + return true + } + + return false +} diff --git a/core/site_battery.go b/core/site_battery.go index 7e67cb55d..f9d09eb7b 100644 --- a/core/site_battery.go +++ b/core/site_battery.go @@ -2,6 +2,7 @@ package core import ( "errors" + "time" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/core/keys" @@ -16,13 +17,6 @@ func (site *Site) batteryConfigured() bool { return len(site.batteryMeters) > 0 } -// GetBatteryMode returns the battery mode -func (site *Site) GetBatteryMode() api.BatteryMode { - site.RLock() - defer site.RUnlock() - return site.batteryMode -} - // setBatteryMode sets the battery mode func (site *Site) setBatteryMode(batMode api.BatteryMode) { site.batteryMode = batMode @@ -39,12 +33,24 @@ func (site *Site) SetBatteryMode(batMode api.BatteryMode) { if site.batteryMode != batMode { site.setBatteryMode(batMode) } + + if site.batteryModeExternal == api.BatteryUnknown { + site.batteryModeExternalTimer = time.Time{} + } } // requiredBatteryMode determines required battery mode based on grid charge and rate func (site *Site) requiredBatteryMode(batteryGridChargeActive bool, rate api.Rate) api.BatteryMode { var res api.BatteryMode batMode := site.GetBatteryMode() + extMode := site.GetBatteryModeExternal() + + var extModeReset bool + if extMode == api.BatteryUnknown { + site.Lock() + extModeReset = !site.batteryModeExternalTimer.IsZero() + site.Unlock() + } mapper := func(s api.BatteryMode) api.BatteryMode { return map[bool]api.BatteryMode{false: s, true: api.BatteryUnknown}[batMode == s] @@ -53,6 +59,11 @@ func (site *Site) requiredBatteryMode(batteryGridChargeActive bool, rate api.Rat switch { case !site.batteryConfigured(): res = api.BatteryUnknown + case extModeReset: + // require normal mode to leave external control + res = api.BatteryNormal + case extMode != api.BatteryUnknown: + res = extMode case batteryGridChargeActive: res = mapper(api.BatteryCharge) case site.dischargeControlActive(rate): diff --git a/core/site_battery_test.go b/core/site_battery_test.go new file mode 100644 index 000000000..16a1528a7 --- /dev/null +++ b/core/site_battery_test.go @@ -0,0 +1,92 @@ +package core + +import ( + "testing" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/config" + "github.com/stretchr/testify/assert" +) + +func TestExternalBatteryMode(t *testing.T) { + for _, tc := range []struct { + internal, ext, new api.BatteryMode + }{ + {api.BatteryUnknown, api.BatteryUnknown, api.BatteryUnknown}, + {api.BatteryUnknown, api.BatteryNormal, api.BatteryNormal}, + {api.BatteryUnknown, api.BatteryCharge, api.BatteryCharge}, + + {api.BatteryNormal, api.BatteryUnknown, api.BatteryUnknown}, + {api.BatteryNormal, api.BatteryNormal, api.BatteryNormal}, + {api.BatteryNormal, api.BatteryCharge, api.BatteryCharge}, + + {api.BatteryCharge, api.BatteryUnknown, api.BatteryNormal}, + {api.BatteryCharge, api.BatteryNormal, api.BatteryNormal}, + {api.BatteryCharge, api.BatteryCharge, api.BatteryCharge}, + } { + t.Logf("%+v", tc) + + site := &Site{ + log: util.NewLogger("foo"), + batteryMeters: []config.Device[api.Meter]{nil}, + } + + site.batteryMode = tc.internal + site.batteryModeExternal = tc.ext + + mode := site.requiredBatteryMode(false, api.Rate{}) + assert.Equal(t, tc.new.String(), mode.String(), "internal mode expected %s got %s", tc.new, mode) + } +} + +func TestExternalBatteryModeChange(t *testing.T) { + for _, tc := range []struct { + internal, ext, expired api.BatteryMode + }{ + {api.BatteryUnknown, api.BatteryUnknown, api.BatteryNormal}, + {api.BatteryUnknown, api.BatteryNormal, api.BatteryNormal}, + {api.BatteryUnknown, api.BatteryCharge, api.BatteryNormal}, + + {api.BatteryNormal, api.BatteryUnknown, api.BatteryNormal}, + {api.BatteryNormal, api.BatteryNormal, api.BatteryNormal}, + {api.BatteryNormal, api.BatteryCharge, api.BatteryNormal}, + + {api.BatteryCharge, api.BatteryUnknown, api.BatteryNormal}, + {api.BatteryCharge, api.BatteryNormal, api.BatteryNormal}, + {api.BatteryCharge, api.BatteryCharge, api.BatteryNormal}, + } { + t.Logf("%+v", tc) + + site := &Site{ + log: util.NewLogger("foo"), + batteryMeters: []config.Device[api.Meter]{nil}, + } + + site.batteryMode = tc.internal + + assert.True(t, site.batteryModeExternalTimer.IsZero()) + site.SetBatteryModeExternal(tc.ext) + + // timer started + if tc.ext != api.BatteryUnknown { + assert.False(t, site.batteryModeExternalTimer.IsZero()) + } + + // expire timer + site.batteryModeExternalTimer = site.batteryModeExternalTimer.Add(-time.Hour) + site.batteryModeWatchdogExpired() + + // mode reverted to unknown, timer still active + assert.Equal(t, site.batteryModeExternal, api.BatteryUnknown) + assert.False(t, site.batteryModeExternalTimer.IsZero()) + + mode := site.requiredBatteryMode(false, api.Rate{}) + assert.Equal(t, tc.expired.String(), mode.String(), "external mode expected %s got %s", tc.expired, mode) + + // timer disabled + site.SetBatteryMode(mode) + assert.True(t, site.batteryModeExternalTimer.IsZero()) + } +} diff --git a/server/http.go b/server/http.go index 32b5cf1a2..3c6572cd1 100644 --- a/server/http.go +++ b/server/http.go @@ -138,6 +138,8 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param) "batterydischargecontrol": {"POST", "/batterydischargecontrol/{value:[01truefalse]+}", boolHandler(site.SetBatteryDischargeControl, site.GetBatteryDischargeControl)}, "batterygridcharge": {"POST", "/batterygridchargelimit/{value:-?[0-9.]+}", floatPtrHandler(pass(site.SetBatteryGridChargeLimit), site.GetBatteryGridChargeLimit)}, "batterygridchargedelete": {"DELETE", "/batterygridchargelimit", floatPtrHandler(pass(site.SetBatteryGridChargeLimit), site.GetBatteryGridChargeLimit)}, + "batterymode": {"POST", "/batterymode/{value:[a-z]+}", updateBatteryMode(site)}, + "batterymodedelete": {"DELETE", "/batterymode", updateBatteryMode(site)}, "prioritysoc": {"POST", "/prioritysoc/{value:[0-9.]+}", floatHandler(site.SetPrioritySoc, site.GetPrioritySoc)}, "residualpower": {"POST", "/residualpower/{value:-?[0-9.]+}", floatHandler(site.SetResidualPower, site.GetResidualPower)}, "smartcost": {"POST", "/smartcostlimit/{value:-?[0-9.]+}", updateSmartCostLimit(site)}, diff --git a/server/http_site_handler.go b/server/http_site_handler.go index 20944393b..9037627c8 100644 --- a/server/http_site_handler.go +++ b/server/http_site_handler.go @@ -196,6 +196,28 @@ func updateSmartCostLimit(site site.API) http.HandlerFunc { } } +// updateBatteryMode sets the external battery mode +func updateBatteryMode(site site.API) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + var val api.BatteryMode + + if r.Method != http.MethodDelete { + s, err := api.BatteryModeString(vars["value"]) + if err != nil { + jsonError(w, http.StatusBadRequest, err) + return + } + + val = s + } + + site.SetBatteryModeExternal(val) + + jsonResult(w, site.GetBatteryModeExternal()) + } +} + // stateHandler returns the combined state func stateHandler(cache *util.ParamCache) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { diff --git a/server/mqtt.go b/server/mqtt.go index 231488821..d5e5a4922 100644 --- a/server/mqtt.go +++ b/server/mqtt.go @@ -15,6 +15,7 @@ import ( "github.com/evcc-io/evcc/core/vehicle" "github.com/evcc-io/evcc/plugin/mqtt" "github.com/evcc-io/evcc/util" + "github.com/samber/lo" ) // MQTTMarshaler is the interface implemented by types that @@ -211,6 +212,12 @@ func (m *MQTT) listenSiteSetters(topic string, site site.API) error { } }))}, {"batteryGridChargeLimit", floatPtrSetter(pass(site.SetBatteryGridChargeLimit))}, + {"batteryMode", ptrSetter(api.BatteryModeString, pass(func(m *api.BatteryMode) { + if m == nil { + m = lo.ToPtr(api.BatteryUnknown) + } + site.SetBatteryModeExternal(*m) + }))}, } { if err := m.Handler.ListenSetter(topic+"/"+s.topic, s.fun); err != nil { return err