Site: add experimental battery grid discharge setting (#32086)

This commit is contained in:
andig 2026-07-23 14:17:45 +02:00 • committed by GitHub
parent f7af0bbe6a
commit 177ca558c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 189 additions and 0 deletions

View file

@ -0,0 +1,60 @@
<template>
<GenericModal
id="batteryModal"
:title="`${$t('config.battery.title')} 🧪`"
config-modal-name="battery"
data-testid="battery-modal"
>
<p>{{ $t("config.battery.description") }}</p>
<ErrorMessage :error="error" />
<div class="form-check form-switch my-3">
<input
id="batteryGridDischarge"
:checked="gridDischarge"
class="form-check-input"
type="checkbox"
role="switch"
@change="change"
/>
<div class="form-check-label">
<label for="batteryGridDischarge">
{{ $t("config.battery.gridDischarge") }}
</label>
</div>
</div>
</GenericModal>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import GenericModal from "../Helper/GenericModal.vue";
import ErrorMessage from "../Helper/ErrorMessage.vue";
import api from "@/api";
import type { AxiosError } from "axios";
export default defineComponent({
name: "BatteryModal",
components: { GenericModal, ErrorMessage },
props: {
gridDischarge: Boolean,
},
data() {
return {
error: null as string | null,
};
},
methods: {
async change(e: Event) {
const target = e.target as HTMLInputElement;
try {
this.error = null;
await api.post(`batterygriddischarge/${target.checked}`);
} catch (err) {
target.checked = this.gridDischarge; // revert on failure to stay in sync with state
const axiosErr = err as AxiosError<{ error: string }>;
this.error = axiosErr.response?.data?.error || axiosErr.message;
}
},
},
});
</script>

View file

@ -29,6 +29,14 @@
@edit="openModal('experimental')"
/>
<GeneralConfigEntry
v-if="experimental && batteryControllable"
test-id="generalconfig-battery"
:label="$t('config.battery.title')"
:text="$t(`config.general.${batteryGridDischarge ? 'on' : 'off'}`)"
@edit="openModal('battery')"
/>
<GeneralConfigEntry
test-id="generalconfig-sponsoring"
:label="$t('config.sponsor.title')"
@ -95,6 +103,12 @@ export default {
telemetryEnabled() {
return store.state?.telemetry === true;
},
batteryControllable() {
return (store.state?.battery ?? []).some((b) => b.controllable);
},
batteryGridDischarge() {
return store.state?.batteryGridDischarge === true;
},
networkStatus() {
return `${store.state?.network?.port ?? ""}`;
},

View file

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

View file

@ -481,6 +481,7 @@
<OptimizerModal :is-sponsor="isSponsor" />
<McpModal />
<ExperimentalModal :experimental="experimental" />
<BatteryModal :grid-discharge="batteryGridDischarge" />
<RemoteModal :remote="remote" :is-sponsor="isSponsor" :site-title="siteTitle" />
<TitleModal @changed="loadDirty" />
<ModbusProxyModal :is-sponsor="isSponsor" @changed="loadDirty" />
@ -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;
},

View file

@ -45,6 +45,7 @@ const (
BatteryDischargeControl = "batteryDischargeControl"
BatteryGridChargeLimit = "batteryGridChargeLimit"
BatteryGridChargeActive = "batteryGridChargeActive"
BatteryGridDischarge = "batteryGridDischarge"
BufferSoc = "bufferSoc"
BufferStartSoc = "bufferStartSoc"

View file

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

View file

@ -91,6 +91,8 @@ type API interface {
GetBatteryDischargeControl() bool
SetBatteryDischargeControl(bool) error
GetBatteryGridDischarge() bool
SetBatteryGridDischarge(bool) error
//
// battery control external

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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