Add battery mode api (#20455)

This commit is contained in:
Ingo 2025-04-11 21:01:46 +02:00 • committed by GitHub
parent 2d31df8e12
commit 7e9bccf0b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 226 additions and 15 deletions

View file

@ -53,4 +53,7 @@ const (
BatteryMode = "batteryMode"
BatteryPower = "batteryPower"
BatterySoc = "batterySoc"
// external battery control
BatteryModeExternal = "batteryModeExternal"
)

View file

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

View file

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

View file

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

View file

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

92
core/site_battery_test.go Normal file
View file

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

View file

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

View file

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

View file

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