From 177ca558c98a155b3e735b258029e8e467a8fe02 Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 23 Jul 2026 14:17:45 +0200 Subject: [PATCH] Site: add experimental battery grid discharge setting (#32086) --- assets/js/components/Config/BatteryModal.vue | 60 +++++++++++++++++++ assets/js/components/Config/GeneralConfig.vue | 14 +++++ assets/js/types/evcc.ts | 2 + assets/js/views/Config.vue | 6 ++ core/keys/site.go | 1 + core/site.go | 7 +++ core/site/api.go | 2 + core/site_api.go | 27 +++++++++ core/site_optimizer.go | 1 + i18n/en.json | 3 + server/http.go | 1 + server/mcp/openapi.json | 27 +++++++++ server/mcp/openapi.md | 20 +++++++ server/mqtt.go | 1 + server/openapi.state.yaml | 3 + server/openapi.yaml | 14 +++++ 16 files changed, 189 insertions(+) create mode 100644 assets/js/components/Config/BatteryModal.vue diff --git a/assets/js/components/Config/BatteryModal.vue b/assets/js/components/Config/BatteryModal.vue new file mode 100644 index 000000000..74d7f8c1a --- /dev/null +++ b/assets/js/components/Config/BatteryModal.vue @@ -0,0 +1,60 @@ + + + diff --git a/assets/js/components/Config/GeneralConfig.vue b/assets/js/components/Config/GeneralConfig.vue index 8f6b98782..fb4f9adc4 100644 --- a/assets/js/components/Config/GeneralConfig.vue +++ b/assets/js/components/Config/GeneralConfig.vue @@ -29,6 +29,14 @@ @edit="openModal('experimental')" /> + + b.controllable); + }, + batteryGridDischarge() { + return store.state?.batteryGridDischarge === true; + }, networkStatus() { return `${store.state?.network?.port ?? ""}`; }, diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index be8a98e05..b5a541e59 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -262,6 +262,8 @@ export interface State { bufferStartSoc?: number; /** Home battery discharge is prevented during fast charging and planned charging. */ batteryDischargeControl?: boolean; + /** Home battery is allowed to discharge to the grid (experimental). */ + batteryGridDischarge?: boolean; solarAdjusted?: boolean; /** Price or emission limit for charging the home battery from grid. */ batteryGridChargeLimit?: number | null; diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue index cdbadbe52..8fd3efb35 100644 --- a/assets/js/views/Config.vue +++ b/assets/js/views/Config.vue @@ -481,6 +481,7 @@ + @@ -559,6 +560,7 @@ import TariffCard from "../components/Config/TariffCard.vue"; import TariffModal from "../components/Config/TariffModal.vue"; import TelemetryModal from "../components/Config/TelemetryModal.vue"; import ExperimentalModal from "../components/Config/ExperimentalModal.vue"; +import BatteryModal from "../components/Config/BatteryModal.vue"; import TitleModal from "../components/Config/TitleModal.vue"; import Header from "../components/Top/Header.vue"; import VehicleIcon from "../components/VehicleIcon"; @@ -648,6 +650,7 @@ export default defineComponent({ TariffModal, TelemetryModal, ExperimentalModal, + BatteryModal, TitleModal, TopHeader: Header, VehicleIcon, @@ -922,6 +925,9 @@ export default defineComponent({ experimental() { return store.state?.experimental; }, + batteryGridDischarge() { + return store.state?.batteryGridDischarge === true; + }, eebus() { return store.state?.eebus; }, diff --git a/core/keys/site.go b/core/keys/site.go index 4c2cae10f..94988f1e3 100644 --- a/core/keys/site.go +++ b/core/keys/site.go @@ -45,6 +45,7 @@ const ( BatteryDischargeControl = "batteryDischargeControl" BatteryGridChargeLimit = "batteryGridChargeLimit" BatteryGridChargeActive = "batteryGridChargeActive" + BatteryGridDischarge = "batteryGridDischarge" BufferSoc = "bufferSoc" BufferStartSoc = "bufferStartSoc" diff --git a/core/site.go b/core/site.go index bb85c8c5f..aa10371e3 100644 --- a/core/site.go +++ b/core/site.go @@ -86,6 +86,7 @@ type Site struct { bufferStartSoc float64 // start charging on battery above this Soc batteryDischargeControl bool // prevent battery discharge for fast and planned charging batteryGridChargeLimit *float64 // grid charging limit + batteryGridDischarge bool // allow battery discharge to grid (experimental) // forecast settings solarAdjusted bool // adjust solar forecast to real production data @@ -383,6 +384,11 @@ func (site *Site) restoreSettings() error { return err } } + if v, err := settings.Bool(keys.BatteryGridDischarge); err == nil { + if err := site.SetBatteryGridDischarge(v); err != nil && !errors.Is(err, ErrBatteryControlNotAvailable) { + return err + } + } if v, err := settings.Float(keys.ResidualPower); err == nil { if err := site.SetResidualPower(v); err != nil { return err @@ -1210,6 +1216,7 @@ func (site *Site) prepare() { site.publish(keys.BufferStartSoc, site.bufferStartSoc) site.publish(keys.BatteryMode, site.batteryMode) site.publish(keys.BatteryDischargeControl, site.batteryDischargeControl) + site.publish(keys.BatteryGridDischarge, site.batteryGridDischarge) site.publish(keys.SolarAdjusted, site.solarAdjusted) site.publish(keys.ResidualPower, site.GetResidualPower()) site.publish(keys.SmartCostAvailable, site.isDynamicTariff(api.TariffUsagePlanner)) diff --git a/core/site/api.go b/core/site/api.go index 2971f09a6..a38b8caea 100644 --- a/core/site/api.go +++ b/core/site/api.go @@ -91,6 +91,8 @@ type API interface { GetBatteryDischargeControl() bool SetBatteryDischargeControl(bool) error + GetBatteryGridDischarge() bool + SetBatteryGridDischarge(bool) error // // battery control external diff --git a/core/site_api.go b/core/site_api.go index 72e5b5286..39e6a3791 100644 --- a/core/site_api.go +++ b/core/site_api.go @@ -370,6 +370,33 @@ func (site *Site) SetBatteryDischargeControl(val bool) error { return nil } +// GetBatteryGridDischarge returns whether the battery may discharge to grid (experimental) +func (site *Site) GetBatteryGridDischarge() bool { + site.RLock() + defer site.RUnlock() + return site.batteryGridDischarge +} + +// SetBatteryGridDischarge sets whether the battery may discharge to grid (experimental) +func (site *Site) SetBatteryGridDischarge(val bool) error { + site.log.DEBUG.Println("set battery grid discharge:", val) + + if !site.hasBatteryControl() { + return ErrBatteryControlNotAvailable + } + + site.Lock() + defer site.Unlock() + + if site.batteryGridDischarge != val { + site.batteryGridDischarge = val + settings.SetBool(keys.BatteryGridDischarge, val) + site.publish(keys.BatteryGridDischarge, val) + } + + return nil +} + // GetSolarAdjusted returns if the solar forecast is adjusted to real production data func (site *Site) GetSolarAdjusted() bool { site.RLock() diff --git a/core/site_optimizer.go b/core/site_optimizer.go index be3b59ee1..0fc3fff5e 100644 --- a/core/site_optimizer.go +++ b/core/site_optimizer.go @@ -796,6 +796,7 @@ func (site *Site) batteryRequest(dev config.Device[api.Meter], b types.Measureme controllable := api.HasCap[api.BatteryController](instance) if controllable { bat.ChargeFromGrid = true + bat.DischargeToGrid = site.GetBatteryGridDischarge() } if m, ok := api.Cap[api.BatteryPowerLimiter](instance); ok { diff --git a/i18n/en.json b/i18n/en.json index 47aa5303f..ac2eb6526 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -118,6 +118,9 @@ "titleEdit": "Edit Self-Regulating Consumer" }, "battery": { + "description": "Experimental battery controls. These features are still being tested and may change or be removed in future versions.", + "gridDischarge": "Allow battery to discharge to the grid", + "title": "Battery", "titleAdd": "Add Battery", "titleEdit": "Edit Battery" }, diff --git a/server/http.go b/server/http.go index e074b3ca4..346d0eb24 100644 --- a/server/http.go +++ b/server/http.go @@ -144,6 +144,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API) { "buffersoc": {"POST", "/buffersoc/{value:[0-9.]+}", floatHandler(site.SetBufferSoc, site.GetBufferSoc)}, "bufferstartsoc": {"POST", "/bufferstartsoc/{value:[0-9.]+}", floatHandler(site.SetBufferStartSoc, site.GetBufferStartSoc)}, "batterydischargecontrol": {"POST", "/batterydischargecontrol/{value:[01truefalse]+}", boolHandler(site.SetBatteryDischargeControl, site.GetBatteryDischargeControl)}, + "batterygriddischarge": {"POST", "/batterygriddischarge/{value:[01truefalse]+}", boolHandler(site.SetBatteryGridDischarge, site.GetBatteryGridDischarge)}, "batterygridcharge": {"POST", "/batterygridchargelimit/{value:-?[0-9.]+}", floatPtrHandler(site.SetBatteryGridChargeLimit, site.GetBatteryGridChargeLimit)}, "batterygridchargedelete": {"DELETE", "/batterygridchargelimit", floatPtrHandler(site.SetBatteryGridChargeLimit, site.GetBatteryGridChargeLimit)}, "batterymode": {"POST", "/batterymode/{value:[a-z]+}", updateBatteryMode(site)}, diff --git a/server/mcp/openapi.json b/server/mcp/openapi.json index 1c221c02c..177d919e9 100644 --- a/server/mcp/openapi.json +++ b/server/mcp/openapi.json @@ -283,6 +283,29 @@ } } }, + "/batterygriddischarge/{enable}": { + "post": { + "operationId": "setBatteryGridDischarge", + "summary": "Control battery grid discharge", + "description": "Allow the home battery to discharge to the grid (experimental).", + "externalDocs": { + "url": "https://docs.evcc.io/en/features/battery" + }, + "tags": [ + "battery" + ], + "parameters": [ + { + "$ref": "#/components/parameters/enable" + } + ], + "responses": { + "200": { + "$ref": "#/components/responses/BooleanResult" + } + } + } + }, "/batterygridchargelimit": { "delete": { "operationId": "removeBatteryGridChargeLimit", @@ -4958,6 +4981,10 @@ "description": "Home battery discharge is prevented during fast charging and planned charging.", "type": "boolean" }, + "batteryGridDischarge": { + "description": "Home battery is allowed to discharge to the grid (experimental).", + "type": "boolean" + }, "solarAdjusted": { "type": "boolean" }, diff --git a/server/mcp/openapi.md b/server/mcp/openapi.md index f1dc94eee..d98f89002 100644 --- a/server/mcp/openapi.md +++ b/server/mcp/openapi.md @@ -146,6 +146,26 @@ call setBatteryGridChargeLimit { } ``` +## setBatteryGridDischarge + +Allow the home battery to discharge to the grid (experimental). + +**Tags:** battery + +**Arguments:** + +| Name | Type | Description | +|------|------|-------------| +| enable | string | Charging mode. | + +**Example call:** + +```json +call setBatteryGridDischarge { + "enable": "true" +} +``` + ## setBufferSoc Set battery buffer SoC. diff --git a/server/mqtt.go b/server/mqtt.go index 9c0f539d8..bc4a881cc 100644 --- a/server/mqtt.go +++ b/server/mqtt.go @@ -215,6 +215,7 @@ func (m *MQTT) listenSiteSetters(topic string, site site.API) error { {"bufferSoc", floatSetter(site.SetBufferSoc)}, {"bufferStartSoc", floatSetter(site.SetBufferStartSoc)}, {"batteryDischargeControl", boolSetter(site.SetBatteryDischargeControl)}, + {"batteryGridDischarge", boolSetter(site.SetBatteryGridDischarge)}, {"prioritySoc", floatSetter(site.SetPrioritySoc)}, {"residualPower", floatSetter(site.SetResidualPower)}, {"solarAdjusted", boolSetter(pass(site.SetSolarAdjusted))}, diff --git a/server/openapi.state.yaml b/server/openapi.state.yaml index e69ad4887..7dd1746a0 100644 --- a/server/openapi.state.yaml +++ b/server/openapi.state.yaml @@ -193,6 +193,9 @@ components: batteryDischargeControl: description: Home battery discharge is prevented during fast charging and planned charging. type: boolean + batteryGridDischarge: + description: Home battery is allowed to discharge to the grid (experimental). + type: boolean solarAdjusted: type: boolean batteryGridChargeLimit: diff --git a/server/openapi.yaml b/server/openapi.yaml index faa15ff6b..2954b8c08 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -181,6 +181,20 @@ paths: responses: "200": $ref: "#/components/responses/BooleanResult" + /batterygriddischarge/{enable}: + post: + operationId: setBatteryGridDischarge + summary: Control battery grid discharge + description: "Allow the home battery to discharge to the grid (experimental)." + externalDocs: + url: https://docs.evcc.io/en/features/battery + tags: + - battery + parameters: + - $ref: "#/components/parameters/enable" + responses: + "200": + $ref: "#/components/responses/BooleanResult" /batterygridchargelimit: delete: operationId: removeBatteryGridChargeLimit