Repository: evcc-io/evcc
Pull request #10815 (2023-11-17 10:14:06)

Battery control: add api
Fix https://github.com/evcc-io/evcc/issues/10810

Refs https://github.com/evcc-io/evcc/issues/10379

- [x] fix `batteryMode` publishing

/cc @GrimmiMeloni 

naltatis: Das ist jetzt sehr direkt Zugriff auf die Steuerung. Lass uns hier auf der Use-Case Ebene bleiben. Die eigentliche Modus-Änderung macht evcc doch abhängig von der Situation intern.

UC 1: Keine Entladung bei Schnell und Plan (entspicht batteryDischargeControl)
UC 2: Lade aus dem Netz wenn ... (später)
UC 3: ...

Ich würd das als einzelne Einstellungen/Eigenschaften sehen, die die Nutzer zu oder abwählen kann.

andig: Ich verstehe leider nur noch Bahnhof. Die Anforderung war:

> Make battery control https://github.com/evcc-io/evcc/pull/10553 visible in the UI.

Genau das ist hier umgesetzt. 

> Das ist jetzt sehr direkt Zugriff auf die Steuerung. 

M.E. ist es das nicht. Hier werden die Features der Steuerung aktiviert. Es wird aber nicht gesteuert.

Ich schlage vor, dass wir den Schriftmodus verlassen...
GrimmiMeloni: Ja, das ist nur An/Aus. ;)

/lgtm
naltatis: @andig wie besprochen hab ich das jetzt erstmal wieder auf ein Bool zurückgebaut und das passende UI Setting eingeführt. Sobald wir den nächsten Modus haben können wir das noch mal wieder diskutieren.

![Bildschirmfoto 2023-11-20 um 00 17 10](https://github.com/evcc-io/evcc/assets/152287/6ff8a18f-1399-46c2-8157-702a239c304f)

![Bildschirmfoto 2023-11-20 um 00 16 58](https://github.com/evcc-io/evcc/assets/152287/eabf0892-9281-4b33-a825-95050219227c)

Der `hold` Modus wird nun textlich mit "Batterie (gesperrt)" / "Battery (locked)" angezeigt.

Hier gibts allerdings noch ein Problem mit der Aktualisierung von `batteryMode`. Wenn ich "dischargeControl" deaktiviere, würde ich erwarten, dass der Modus wieder auf "normal" zurückgeht. Das klappt aber nicht. Erst nach einem Neustart ist der Modus wieder korrekt. Hier ist das Publishing vmtl noch nicht ganz gerade. \\ @andig @GrimmiMeloni 

@markusrem ich hab überlegt hier ein Schloss Icon 🔒 zu verwenden. In den Shopicons ist allerdings nur ein offenes Schloss vorhanden 🔓 (`lock`). Vielleicht kannst du das bei Gelegenheit mal ergänzen.
naltatis: Anderes Thema: Cool wäre, wenn wir die Info, ob Battery Control von der Hardware unterstützt wird auch noch ermitteln und publishen können. Dann würden wir die Option auch nur anbieten, wenns überhaupt geht. Geht in Richtung Capabilities, die wir auch bei den Wallboxen haben. Ist auch ein spannendes Thema für die Doku.
andig: Wir an den Batterien als `controllable` angezeigt. Beim disablen wird wieder "normal" hergestellt. Das kann einen Fehler zurück geben.
andig: @GrimmiMeloni könntest Du mal die Tests anschauen? Die müssten jetzt nach "Modus bestimmen" und "Modus setzen" aufgesplittet werden- für mich ist das aber adhoc nicht eingängig. Vielleicht jetzt wo es läuft nochmal deutlich vereinfachen und nur Modusbestimmung testen, also `loadpointBatteryMode`?
GrimmiMeloni: > @GrimmiMeloni könntest Du mal die Tests anschauen? Die müssten jetzt nach "Modus bestimmen" und "Modus setzen" aufgesplittet werden- für mich ist das aber adhoc nicht eingängig. Vielleicht jetzt wo es läuft nochmal deutlich vereinfachen und nur Modusbestimmung testen, also `loadpointBatteryMode`?

Mache ich. Wird aber frühestens heute Abend was.
andig: /cc @VolkerK62 neues API: https://github.com/evcc-io/evcc/pull/10815/files#diff-9dc8ed5b73297cf3c3d1d32cb36520c8f982a0194fad1e7b3fc521c5799e2023R111
VolkerK62: @naltatis  machst du dann auch die Ergänzung in der Doku (mit Bild) analog den anderen Batterie-Einstellungen?

Patch:
diff --git a/assets/js/components/BatterySettingsModal.vue b/assets/js/components/BatterySettingsModal.vue
index c094ef91..ed8816d8 100644
--- a/assets/js/components/BatterySettingsModal.vue
+++ b/assets/js/components/BatterySettingsModal.vue
@@ -22,200 +22,232 @@
 							aria-label="Close"
 						></button>
 					</div>
-					<div
-						class="modal-body d-flex justify-content-between align-items-center align-items-sm-start pb-2 flex-column flex-sm-row-reverse"
-					>
-						<div class="battery mb-5 mb-sm-3 me-5 me-sm-0">
-							<div class="batteryLimits">
-								<label
-									class="bufferSoc p-2 end-0"
-									role="button"
-									:class="{
-										'bufferSoc--hidden':
-											selectedBufferSoc === selectedPrioritySoc,
-									}"
-									:style="{ top: `${topHeight}%` }"
-								>
-									<select
-										:value="selectedBufferSoc"
-										class="custom-select"
-										@change="changeBufferSoc"
+					<div class="modal-body">
+						<div
+							class="d-flex justify-content-between align-items-center align-items-sm-start pb-2 flex-column flex-sm-row-reverse"
+						>
+							<div class="battery mb-5 mb-sm-3 me-5 me-sm-0">
+								<div class="batteryLimits">
+									<label
+										class="bufferSoc p-2 end-0"
+										role="button"
+										:class="{
+											'bufferSoc--hidden':
+												selectedBufferSoc === selectedPrioritySoc,
+										}"
+										:style="{ top: `${topHeight}%` }"
 									>
-										<option
-											v-for="{ value, name, disabled } in bufferOptions"
-											:key="value"
-											:value="value"
-											:disabled="disabled"
+										<select
+											:value="selectedBufferSoc"
+											class="custom-select"
+											@change="changeBufferSoc"
 										>
-											{{ name }}
-										</option>
-									</select>
-									<span class="text-decoration-underline text-nowrap pe-none">
-										{{ fmtSoc(selectedBufferSoc) }}
-									</span>
-								</label>
-								<label
-									class="prioritySoc p-2 end-0"
-									role="button"
-									:style="{ top: `${100 - bottomHeight}%` }"
-								>
-									<select
-										:value="selectedPrioritySoc"
-										class="custom-select"
-										@change="changePrioritySoc"
+											<option
+												v-for="{ value, name, disabled } in bufferOptions"
+												:key="value"
+												:value="value"
+												:disabled="disabled"
+											>
+												{{ name }}
+											</option>
+										</select>
+										<span class="text-decoration-underline text-nowrap pe-none">
+											{{ fmtSoc(selectedBufferSoc) }}
+										</span>
+									</label>
+									<label
+										class="prioritySoc p-2 end-0"
+										role="button"
+										:style="{ top: `${100 - bottomHeight}%` }"
 									>
-										<option
-											v-for="{ value, name, disabled } in priorityOptions"
-											:key="value"
-											:value="value"
-											:disabled="disabled"
+										<select
+											:value="selectedPrioritySoc"
+											class="custom-select"
+											@change="changePrioritySoc"
 										>
-											{{ name }}
-										</option>
-									</select>
-									<span class="text-decoration-underline text-nowrap pe-none">
-										{{ fmtSoc(selectedPrioritySoc) }}
-									</span>
-								</label>
+											<option
+												v-for="{ value, name, disabled } in priorityOptions"
+												:key="value"
+												:value="value"
+												:disabled="disabled"
+											>
+												{{ name }}
+											</option>
+										</select>
+										<span class="text-decoration-underline text-nowrap pe-none">
+											{{ fmtSoc(selectedPrioritySoc) }}
+										</span>
+									</label>
+								</div>
+								<div class="progress">
+									<div
+										class="bg-dark-green progress-bar text-light align-items-center"
+										role="button"
+										:style="{ height: `${topHeight}%` }"
+										@click="toggleBufferStart"
+									>
+										<shopicon-regular-lightning
+											size="m"
+											class="icon"
+											:style="iconStyle(topHeight)"
+										></shopicon-regular-lightning>
+									</div>
+									<div
+										class="bg-darker-green progress-bar text-light align-items-center"
+										:style="{ height: `${middleHeight}%` }"
+									>
+										<shopicon-regular-car3
+											size="m"
+											class="icon"
+											:style="iconStyle(middleHeight)"
+										></shopicon-regular-car3>
+									</div>
+									<div
+										class="bg-darkest-green progress-bar text-light align-items-center"
+										:style="{ height: `${bottomHeight}%` }"
+									>
+										<shopicon-regular-home
+											size="m"
+											class="icon"
+											:style="iconStyle(bottomHeight)"
+										></shopicon-regular-home>
+									</div>
+									<div
+										class="batterySoc ps-0 bg-white pe-none"
+										:style="{ top: `${100 - batterySoc}%` }"
+									></div>
+									<div
+										class="bufferStartIndicator pe-none"
+										:class="{
+											'bufferStartIndicator--hidden':
+												!selectedBufferStartSoc ||
+												selectedBufferSoc === 100,
+										}"
+										:style="{ top: `${bufferStartTop}%` }"
+									>
+										<div class="bufferStartIndicator__left"></div>
+										<div class="bufferStartIndicator__right"></div>
+									</div>
+								</div>
 							</div>
-							<div class="progress">
-								<div
-									class="bg-dark-green progress-bar text-light align-items-center"
-									role="button"
-									:style="{ height: `${topHeight}%` }"
-									@click="toggleBufferStart"
-								>
+							<div class="me-sm-4">
+								<p>
+									{{ $t("batterySettings.batteryLevel") }}:
+									<strong>{{ fmtSoc(batterySoc) }}</strong>
+									<small
+										v-for="(line, index) in batteryDetails"
+										:key="index"
+										class="d-block"
+									>
+										{{ line }}
+									</small>
+								</p>
+								<p>{{ $t("batterySettings.legendTitle") }}</p>
+								<p class="d-flex">
 									<shopicon-regular-lightning
-										size="m"
-										class="icon"
-										:style="iconStyle(topHeight)"
+										size="s"
+										class="flex-shrink-0 me-2"
 									></shopicon-regular-lightning>
-								</div>
-								<div
-									class="bg-darker-green progress-bar text-light align-items-center"
-									:style="{ height: `${middleHeight}%` }"
-								>
+									<span class="d-block">
+										{{ $t("batterySettings.legendTopName") }}
+										<small v-if="selectedBufferSoc == 100" class="d-block">
+											{{ $t("batterySettings.legendTopSubline") }}
+										</small>
+										<small v-else class="d-block">
+											{{ $t("batterySettings.legendTopAutostart") }}
+											<label
+												for="bufferStartSelect"
+												class="position-relative d-block"
+											>
+												<select
+													id="bufferStartSelect"
+													class="custom-select"
+													@change="changeBufferStart"
+												>
+													<option
+														v-for="option in [
+															'never',
+															'full',
+															'medium',
+															'low',
+														]"
+														:key="option"
+														:value="option"
+													>
+														{{
+															$t(
+																`batterySettings.bufferStart.${option}`
+															)
+														}}
+													</option>
+												</select>
+												<span class="text-decoration-underline">
+													{{
+														$t(
+															`batterySettings.bufferStart.${bufferStartOption}`
+														)
+													}}
+												</span>
+											</label>
+										</small>
+									</span>
+								</p>
+								<p class="d-flex">
 									<shopicon-regular-car3
-										size="m"
-										class="icon"
-										:style="iconStyle(middleHeight)"
+										size="s"
+										class="flex-shrink-0 me-2"
 									></shopicon-regular-car3>
-								</div>
-								<div
-									class="bg-darkest-green progress-bar text-light align-items-center"
-									:style="{ height: `${bottomHeight}%` }"
-								>
+									<span class="d-block">
+										{{ $t("batterySettings.legendMiddleName") }}
+										<small class="d-block">
+											{{ $t("batterySettings.legendMiddleSubline") }}
+										</small>
+									</span>
+								</p>
+								<p class="d-flex">
 									<shopicon-regular-home
-										size="m"
-										class="icon"
-										:style="iconStyle(bottomHeight)"
+										size="s"
+										class="flex-shrink-0 me-2"
 									></shopicon-regular-home>
-								</div>
-								<div
-									class="batterySoc ps-0 bg-white pe-none"
-									:style="{ top: `${100 - batterySoc}%` }"
-								></div>
-								<div
-									class="bufferStartIndicator pe-none"
-									:class="{
-										'bufferStartIndicator--hidden':
-											!selectedBufferStartSoc || selectedBufferSoc === 100,
-									}"
-									:style="{ top: `${bufferStartTop}%` }"
-								>
-									<div class="bufferStartIndicator__left"></div>
-									<div class="bufferStartIndicator__right"></div>
-								</div>
+									<span class="d-block">
+										{{ $t("batterySettings.legendBottomName") }}
+										<small class="d-block">
+											{{ $t("batterySettings.legendBottomSubline") }}
+										</small>
+									</span>
+								</p>
+								<p>
+									<small>
+										{{ $t("batterySettings.note") }}
+									</small>
+								</p>
 							</div>
 						</div>
-						<div class="me-sm-4">
-							<p>
-								{{ $t("batterySettings.batteryLevel") }}:
-								<strong>{{ fmtSoc(batterySoc) }}</strong>
-								<small
-									v-for="(line, index) in batteryDetails"
-									:key="index"
-									class="d-block"
-								>
-									{{ line }}
-								</small>
-							</p>
-							<p>{{ $t("batterySettings.legendTitle") }}</p>
-							<p class="d-flex">
-								<shopicon-regular-lightning
-									size="s"
-									class="flex-shrink-0 me-2"
-								></shopicon-regular-lightning>
-								<span class="d-block">
-									{{ $t("batterySettings.legendTopName") }}
-									<small v-if="selectedBufferSoc == 100" class="d-block">
-										{{ $t("batterySettings.legendTopSubline") }}
-									</small>
-									<small v-else class="d-block">
-										{{ $t("batterySettings.legendTopAutostart") }}
-										<label
-											for="bufferStartSelect"
-											class="position-relative d-block"
-										>
-											<select
-												id="bufferStartSelect"
-												class="custom-select"
-												@change="changeBufferStart"
-											>
-												<option
-													v-for="option in [
-														'never',
-														'full',
-														'medium',
-														'low',
-													]"
-													:key="option"
-													:value="option"
-												>
-													{{
-														$t(`batterySettings.bufferStart.${option}`)
-													}}
-												</option>
-											</select>
-											<span class="text-decoration-underline">
-												{{
-													$t(
-														`batterySettings.bufferStart.${bufferStartOption}`
-													)
-												}}
-											</span>
+						<div v-if="$hiddenFeatures()">
+							<FormRow
+								v-if="controllable"
+								id="batteryDischargeControl"
+								:label="`${$t('batterySettings.control')}`"
+							>
+								<div class="form-check form-switch col-form-label">
+									<input
+										id="batteryDischargeControl"
+										:checked="batteryDischargeControl"
+										class="form-check-input"
+										type="checkbox"
+										role="switch"
+										@change="changeDischargeControl"
+									/>
+									<div class="form-check-label">
+										<label for="batteryDischargeControl">
+											{{ $t("batterySettings.discharge") }}
+											<span title="experimental">🧪</span>
 										</label>
-									</small>
-								</span>
-							</p>
-							<p class="d-flex">
-								<shopicon-regular-car3
-									size="s"
-									class="flex-shrink-0 me-2"
-								></shopicon-regular-car3>
-								<span class="d-block">
-									{{ $t("batterySettings.legendMiddleName") }}
-									<small class="d-block">
-										{{ $t("batterySettings.legendMiddleSubline") }}
-									</small>
-								</span>
-							</p>
-							<p class="d-flex">
-								<shopicon-regular-home
-									size="s"
-									class="flex-shrink-0 me-2"
-								></shopicon-regular-home>
-								<span class="d-block">
-									{{ $t("batterySettings.legendBottomName") }}
-									<small class="d-block">
-										{{ $t("batterySettings.legendBottomSubline") }}
-									</small>
-								</span>
-							</p>
-							<p>
-								<small>
-									{{ $t("batterySettings.note") }}
-								</small>
+									</div>
+								</div>
+							</FormRow>
+							<p v-else>
+								<small>{{ $t("batterySettings.notControllable") }}</small>
 							</p>
 						</div>
 					</div>
@@ -230,18 +262,21 @@ import "@h2d2/shopicons/es/regular/lightning";
 import "@h2d2/shopicons/es/regular/car3";
 import "@h2d2/shopicons/es/regular/home";
 import formatter from "../mixins/formatter";
+import FormRow from "./FormRow.vue";
 
 import api from "../api";
 
 export default {
 	name: "BatterySettingsModal",
+	components: { FormRow },
 	mixins: [formatter],
 	props: {
 		bufferSoc: Number,
 		prioritySoc: Number,
 		batterySoc: Number,
 		bufferStartSoc: Number,
-		battery: { type: Array },
+		batteryDischargeControl: Boolean,
+		battery: { type: Array, default: () => [] },
 	},
 	data: function () {
 		return {
@@ -263,6 +298,9 @@ export default {
 			}
 			return options;
 		},
+		controllable() {
+			return this.battery.some(({ controllable }) => controllable);
+		},
 		bufferOptions() {
 			const options = [];
 			for (let i = 100; i >= 5; i -= 5) {
@@ -428,6 +466,13 @@ export default {
 		fmtSoc(soc) {
 			return `${Math.round(soc)}%`;
 		},
+		async changeDischargeControl(e) {
+			try {
+				await api.post(`batterydischargecontrol/${e.target.checked ? "true" : "false"}`);
+			} catch (err) {
+				console.error(err);
+			}
+		},
 	},
 };
 </script>
diff --git a/assets/js/components/Energyflow/Energyflow.vue b/assets/js/components/Energyflow/Energyflow.vue
index b7b0ffdb..b5317d08 100644
--- a/assets/js/components/Energyflow/Energyflow.vue
+++ b/assets/js/components/Energyflow/Energyflow.vue
@@ -70,7 +70,7 @@
 						/>
 						<EnergyflowEntry
 							v-if="batteryConfigured"
-							:name="$t('main.energyflow.batteryDischarge')"
+							:name="batteryDischargeLabel"
 							icon="battery"
 							:power="batteryDischarge"
 							:powerInKw="powerInKw"
@@ -134,7 +134,7 @@
 						/>
 						<EnergyflowEntry
 							v-if="batteryConfigured"
-							:name="$t('main.energyflow.batteryCharge')"
+							:name="batteryChargeLabel"
 							icon="battery"
 							:power="batteryCharge"
 							:powerInKw="powerInKw"
@@ -197,6 +197,8 @@ export default {
 		battery: { type: Array },
 		batteryPower: { type: Number, default: 0 },
 		batterySoc: { type: Number, default: 0 },
+		batteryDischargeControl: { type: Boolean },
+		batteryMode: { type: String },
 		tariffGrid: { type: Number },
 		tariffFeedIn: { type: Number },
 		tariffCo2: { type: Number },
@@ -230,6 +232,15 @@ export default {
 		batteryCharge: function () {
 			return Math.abs(Math.min(0, this.batteryPower) * -1);
 		},
+		batteryChargeLabel: function () {
+			return this.$t(`main.energyflow.battery${this.batteryHold ? "Hold" : "Charge"}`);
+		},
+		batteryDischargeLabel: function () {
+			return this.$t(`main.energyflow.battery${this.batteryHold ? "Hold" : "Discharge"}`);
+		},
+		batteryHold: function () {
+			return this.batteryMode === "hold";
+		},
 		selfConsumption: function () {
 			const ownPower = this.batteryDischarge + this.pvProduction;
 			const consumption = this.homePower + this.batteryCharge + this.loadpointsPower;
diff --git a/assets/js/components/GlobalSettingsModal.vue b/assets/js/components/GlobalSettingsModal.vue
index 1fae002e..f70c6c26 100644
--- a/assets/js/components/GlobalSettingsModal.vue
+++ b/assets/js/components/GlobalSettingsModal.vue
@@ -71,7 +71,7 @@
 								id="hiddenFeaturesEnabled"
 								:label="`${$t('settings.hiddenFeatures.label')} 🧪`"
 							>
-								<div class="form-check form-switch my-1">
+								<div class="form-check form-switch col-form-label">
 									<input
 										id="hiddenFeaturesEnabled"
 										v-model="hiddenFeatures"
diff --git a/assets/js/components/Site.vue b/assets/js/components/Site.vue
index bb5bac05..a1c61d0e 100644
--- a/assets/js/components/Site.vue
+++ b/assets/js/components/Site.vue
@@ -69,6 +69,8 @@ export default {
 		batteryConfigured: Boolean,
 		batteryPower: Number,
 		batterySoc: Number,
+		batteryDischargeControl: Boolean,
+		batteryMode: String,
 		battery: Array,
 		gridCurrents: Array,
 		prioritySoc: Number,
diff --git a/core/site.go b/core/site.go
index b190599a..b808467d 100644
--- a/core/site.go
+++ b/core/site.go
@@ -41,10 +41,11 @@ type meterMeasurement struct {
 
 // batteryMeasurement is used as slice element for publishing structured data
 type batteryMeasurement struct {
-	Power    float64 `json:"power"`
-	Energy   float64 `json:"energy,omitempty"`
-	Soc      float64 `json:"soc,omitempty"`
-	Capacity float64 `json:"capacity,omitempty"`
+	Power        float64 `json:"power"`
+	Energy       float64 `json:"energy,omitempty"`
+	Soc          float64 `json:"soc,omitempty"`
+	Capacity     float64 `json:"capacity,omitempty"`
+	Controllable bool    `json:"controllable"`
 }
 
 // Site is the main configuration container. A site can host multiple loadpoints.
@@ -67,7 +68,7 @@ type Site struct {
 	BufferStartSoc                    float64      `mapstructure:"bufferStartSoc"`                    // start charging on battery above this Soc
 	MaxGridSupplyWhileBatteryCharging float64      `mapstructure:"maxGridSupplyWhileBatteryCharging"` // ignore battery charging if AC consumption is above this value
 	SmartCostLimit                    float64      `mapstructure:"smartCostLimit"`                    // always charge if cost is below this value
-	BatteryDischargeControl           bool         `mapstructure:"batteryDischargeControl"`           // shall discharge of home battery be adjusted
+	BatteryDischargeControl           bool         `mapstructure:"batteryDischargeControl"`           // prevent battery discharge for fast and planned charging
 
 	// meters
 	gridMeter     api.Meter   // Grid usage meter
@@ -86,7 +87,7 @@ type Site struct {
 	pvPower      float64         // PV power
 	batteryPower float64         // Battery charge power
 	batterySoc   float64         // Battery soc
-	batteryMode  api.BatteryMode // Battery discharge currently enabled
+	batteryMode  api.BatteryMode // Battery mode
 
 	publishCache map[string]any // store last published values to avoid unnecessary republishing
 }
@@ -217,6 +218,15 @@ func NewSiteFromConfig(
 		site.log.WARN.Println("bufferSoc must be larger than prioritySoc")
 	}
 
+	// revert battery mode on shutdown
+	shutdown.Register(func() {
+		if mode := site.GetBatteryMode(); mode != api.BatteryUnknown && mode != api.BatteryNormal {
+			if err := site.updateBatteryMode(api.BatteryNormal); err != nil {
+				site.log.ERROR.Println("battery mode:", err)
+			}
+		}
+	})
+
 	return site, nil
 }
 
@@ -253,7 +263,7 @@ func (site *Site) restoreSettings() {
 	if v, err := settings.Float("site.smartCostLimit"); err == nil {
 		site.SmartCostLimit = v
 	}
-	if v, err := settings.Bool("site.batteryDischargeControl"); err == nil {
+	if v, err := settings.Bool("site.batteryControl"); err == nil {
 		site.BatteryDischargeControl = v
 	}
 }
@@ -360,6 +370,10 @@ func (site *Site) publish(key string, val interface{}) {
 		return
 	}
 
+	if s, ok := val.(fmt.Stringer); ok {
+		val = s.String()
+	}
+
 	site.uiChan <- util.Param{
 		Key: key,
 		Val: val,
@@ -516,11 +530,14 @@ func (site *Site) updateMeters() error {
 				}
 			}
 
+			_, controllable := meter.(api.BatteryController)
+
 			mm[i] = batteryMeasurement{
-				Power:    power,
-				Energy:   energy,
-				Soc:      batSoc,
-				Capacity: capacity,
+				Power:        power,
+				Energy:       energy,
+				Soc:          batSoc,
+				Capacity:     capacity,
+				Controllable: controllable,
 			}
 		}
 
@@ -795,8 +812,12 @@ func (site *Site) update(lp Updater) {
 		site.log.ERROR.Println(err)
 	}
 
-	if site.BatteryDischargeControl {
-		site.updateBatteryMode(site.Loadpoints())
+	if batMode := site.GetBatteryMode(); site.BatteryDischargeControl {
+		if mode := site.determineBatteryMode(site.Loadpoints()); mode != batMode {
+			if err := site.updateBatteryMode(mode); err != nil {
+				site.log.ERROR.Println("battery mode:", err)
+			}
+		}
 	}
 
 	site.stats.Update(site)
@@ -817,13 +838,13 @@ func (site *Site) prepare() {
 	site.publish("smartCostType", nil)
 	site.publish("smartCostActive", false)
 	if tariff := site.GetTariff(PlannerTariff); tariff != nil {
-		site.publish("smartCostType", tariff.Type().String())
+		site.publish("smartCostType", tariff.Type())
 	}
-	site.publish("currency", site.tariffs.Currency.String())
+	site.publish("currency", site.tariffs.Currency)
 
 	site.publish("vehicles", vehicleTitles(site.GetVehicles()))
 	site.publish("batteryDischargeControl", site.BatteryDischargeControl)
-	site.publish("batteryMode", site.batteryMode.String())
+	site.publish("batteryMode", site.batteryMode)
 }
 
 // Prepare attaches communication channels to site and loadpoints
diff --git a/core/site/api.go b/core/site/api.go
index 217ff69d..3a6123c5 100644
--- a/core/site/api.go
+++ b/core/site/api.go
@@ -43,4 +43,11 @@ type API interface {
 	GetTariff(string) api.Tariff
 	GetSmartCostLimit() float64
 	SetSmartCostLimit(float64) error
+
+	//
+	// battery control
+	//
+
+	GetBatteryDischargeControl() bool
+	SetBatteryDischargeControl(bool) error
 }
diff --git a/core/site_api.go b/core/site_api.go
index 7df9be2d..2423a03e 100644
--- a/core/site_api.go
+++ b/core/site_api.go
@@ -186,3 +186,33 @@ func (site *Site) GetTariff(tariff string) api.Tariff {
 		return nil
 	}
 }
+
+// GetBatteryControl returns the battery control mode
+func (site *Site) GetBatteryDischargeControl() bool {
+	site.Lock()
+	defer site.Unlock()
+	return site.BatteryDischargeControl
+}
+
+// SetBatteryControl sets the battery control mode
+func (site *Site) SetBatteryDischargeControl(val bool) error {
+	site.log.DEBUG.Println("set battery discharge control:", val)
+
+	if site.GetBatteryDischargeControl() != val {
+		// reset to normal when disabling
+		if mode := site.GetBatteryMode(); mode != api.BatteryNormal {
+			if err := site.updateBatteryMode(api.BatteryNormal); err != nil {
+				return err
+			}
+		}
+
+		site.Lock()
+		defer site.Unlock()
+
+		site.BatteryDischargeControl = val
+		settings.SetBool("site.batteryDischargeControl", val)
+		site.publish("batteryDischargeControl", val)
+	}
+
+	return nil
+}
diff --git a/core/site_battery.go b/core/site_battery.go
index 5180c6d7..841aea51 100644
--- a/core/site_battery.go
+++ b/core/site_battery.go
@@ -5,45 +5,43 @@ import (
 	"github.com/evcc-io/evcc/core/loadpoint"
 )
 
-// getBatteryMode returns the battery mode
-func (site *Site) getBatteryMode() api.BatteryMode {
+// GetBatteryMode returns the battery mode
+func (site *Site) GetBatteryMode() api.BatteryMode {
 	site.Lock()
 	defer site.Unlock()
 	return site.batteryMode
 }
 
-// setBatteryMode sets the battery mode
-func (site *Site) setBatteryMode(batMode api.BatteryMode) {
+// SetBatteryMode sets the battery mode
+func (site *Site) SetBatteryMode(batMode api.BatteryMode) {
 	site.Lock()
 	defer site.Unlock()
 	site.batteryMode = batMode
-	site.publish("batteryMode", batMode.String())
+	site.publish("batteryMode", batMode)
 }
 
-func (site *Site) updateBatteryMode(loadpoints []loadpoint.API) {
-	// determine expected state
-	batMode := api.BatteryNormal
+func (site *Site) determineBatteryMode(loadpoints []loadpoint.API) api.BatteryMode {
 	for _, lp := range loadpoints {
 		if lp.GetStatus() == api.StatusC && (lp.GetMode() == api.ModeNow || lp.GetPlanActive()) {
-			batMode = api.BatteryHold
-			break
+			return api.BatteryHold
 		}
 	}
 
-	if batMode == site.getBatteryMode() {
-		return
-	}
+	return api.BatteryNormal
+}
 
+func (site *Site) updateBatteryMode(mode api.BatteryMode) error {
 	// update batteries
 	for _, meter := range site.batteryMeters {
 		if batCtrl, ok := meter.(api.BatteryController); ok {
-			if err := batCtrl.SetBatteryMode(batMode); err != nil {
-				site.log.ERROR.Println("battery mode:", err)
-				return
+			if err := batCtrl.SetBatteryMode(mode); err != nil {
+				return err
 			}
 		}
 	}
 
 	// update state and publish
-	site.setBatteryMode(batMode)
+	site.SetBatteryMode(mode)
+
+	return nil
 }
diff --git a/i18n/de.toml b/i18n/de.toml
index b945517b..5abc56b0 100644
--- a/i18n/de.toml
+++ b/i18n/de.toml
@@ -1,6 +1,9 @@
 [batterySettings]
 batteryLevel = "Ladestand"
 capacity = "{energy} von {total}"
+control = "Batteriesteuerung"
+notControllable = "Batteriesteuerung ist für diesen Speicher nicht verfügbar."
+discharge = "Verhindere Entladung im Schnell-Modus und bei geplantem Laden."
 legendBottomName = "Haus hat Priorität"
 legendBottomSubline = "nicht zum Laden verwenden"
 legendMiddleName = "Fahrzeug zuerst"
@@ -174,6 +177,7 @@ titleTargetCharge = "Abfahrt"
 battery = "Batterie"
 batteryCharge = "Batterie laden"
 batteryDischarge = "Batterie entladen"
+batteryHold = "Battery (gesperrt)"
 batteryTooltip = "{energy} von {total} ({soc})"
 gridImport = "Netzbezug"
 homePower = "Verbrauch"
diff --git a/i18n/en.toml b/i18n/en.toml
index 4c7c49d9..84174f59 100644
--- a/i18n/en.toml
+++ b/i18n/en.toml
@@ -1,6 +1,9 @@
 [batterySettings]
 batteryLevel = "Battery level"
 capacity = "{energy} of {total}"
+control = "Battery control"
+notControllable = "Battery control is not available for this battery."
+discharge = "Prevent discharge in fast mode and planned charging."
 legendBottomName = "home priority"
 legendBottomSubline = "not used for charging"
 legendMiddleName = "vehicle first"
@@ -171,6 +174,7 @@ titleTargetCharge = "Departure"
 battery = "Battery"
 batteryCharge = "Battery charging"
 batteryDischarge = "Battery discharging"
+batteryHold = "Battery (locked)"
 batteryTooltip = "{energy} of {total} ({soc})"
 gridImport = "Grid use"
 homePower = "Consumption"
diff --git a/server/http.go b/server/http.go
index 9193eb3f..0f8bc596 100644
--- a/server/http.go
+++ b/server/http.go
@@ -95,28 +95,29 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, cache *util.Cache) {
 
 	// site api
 	routes := map[string]route{
-		"health":         {[]string{"GET"}, "/health", healthHandler(site)},
-		"state":          {[]string{"GET"}, "/state", stateHandler(cache)},
-		"config":         {[]string{"GET"}, "/config/templates/{class:[a-z]+}", templatesHandler},
-		"products":       {[]string{"GET"}, "/config/products/{class:[a-z]+}", productsHandler},
-		"device":         {[]string{"GET"}, "/config/devices/{class:[a-z]+}/{id:[0-9.]+}", deviceHandler},
-		"devices":        {[]string{"GET"}, "/config/devices/{class:[a-z]+}", devicesHandler},
-		"newdevice":      {[]string{"POST", "OPTIONS"}, "/config/devices/{class:[a-z]+}", newDeviceHandler},
-		"updatedevice":   {[]string{"PUT", "OPTIONS"}, "/config/devices/{class:[a-z]+}/{id:[0-9.]+}", updateDeviceHandler},
-		"deletedevice":   {[]string{"DELETE", "OPTIONS"}, "/config/devices/{class:[a-z]+}/{id:[0-9.]+}", deleteDeviceHandler},
-		"testconfig":     {[]string{"POST", "OPTIONS"}, "/config/test/{class:[a-z]+}", testHandler},
-		"testdevice":     {[]string{"POST", "OPTIONS"}, "/config/test/{class:[a-z]+}/{id:[0-9.]+}", testHandler},
-		"buffersoc":      {[]string{"POST", "OPTIONS"}, "/buffersoc/{value:[0-9.]+}", floatHandler(site.SetBufferSoc, site.GetBufferSoc)},
-		"bufferstartsoc": {[]string{"POST", "OPTIONS"}, "/bufferstartsoc/{value:[0-9.]+}", floatHandler(site.SetBufferStartSoc, site.GetBufferStartSoc)},
-		"prioritysoc":    {[]string{"POST", "OPTIONS"}, "/prioritysoc/{value:[0-9.]+}", floatHandler(site.SetPrioritySoc, site.GetPrioritySoc)},
-		"residualpower":  {[]string{"POST", "OPTIONS"}, "/residualpower/{value:[-0-9.]+}", floatHandler(site.SetResidualPower, site.GetResidualPower)},
-		"smartcost":      {[]string{"POST", "OPTIONS"}, "/smartcostlimit/{value:[-0-9.]+}", floatHandler(site.SetSmartCostLimit, site.GetSmartCostLimit)},
-		"tariff":         {[]string{"GET"}, "/tariff/{tariff:[a-z]+}", tariffHandler(site)},
-		"sessions":       {[]string{"GET"}, "/sessions", sessionHandler},
-		"session1":       {[]string{"PUT", "OPTIONS"}, "/session/{id:[0-9]+}", updateSessionHandler},
-		"session2":       {[]string{"DELETE", "OPTIONS"}, "/session/{id:[0-9]+}", deleteSessionHandler},
-		"telemetry":      {[]string{"GET"}, "/settings/telemetry", boolGetHandler(telemetry.Enabled)},
-		"telemetry2":     {[]string{"POST", "OPTIONS"}, "/settings/telemetry/{value:[a-z]+}", boolHandler(telemetry.Enable, telemetry.Enabled)},
+		"health":                  {[]string{"GET"}, "/health", healthHandler(site)},
+		"state":                   {[]string{"GET"}, "/state", stateHandler(cache)},
+		"config":                  {[]string{"GET"}, "/config/templates/{class:[a-z]+}", templatesHandler},
+		"products":                {[]string{"GET"}, "/config/products/{class:[a-z]+}", productsHandler},
+		"device":                  {[]string{"GET"}, "/config/devices/{class:[a-z]+}/{id:[0-9.]+}", deviceHandler},
+		"devices":                 {[]string{"GET"}, "/config/devices/{class:[a-z]+}", devicesHandler},
+		"newdevice":               {[]string{"POST", "OPTIONS"}, "/config/devices/{class:[a-z]+}", newDeviceHandler},
+		"updatedevice":            {[]string{"PUT", "OPTIONS"}, "/config/devices/{class:[a-z]+}/{id:[0-9.]+}", updateDeviceHandler},
+		"deletedevice":            {[]string{"DELETE", "OPTIONS"}, "/config/devices/{class:[a-z]+}/{id:[0-9.]+}", deleteDeviceHandler},
+		"testconfig":              {[]string{"POST", "OPTIONS"}, "/config/test/{class:[a-z]+}", testHandler},
+		"testdevice":              {[]string{"POST", "OPTIONS"}, "/config/test/{class:[a-z]+}/{id:[0-9.]+}", testHandler},
+		"buffersoc":               {[]string{"POST", "OPTIONS"}, "/buffersoc/{value:[0-9.]+}", floatHandler(site.SetBufferSoc, site.GetBufferSoc)},
+		"bufferstartsoc":          {[]string{"POST", "OPTIONS"}, "/bufferstartsoc/{value:[0-9.]+}", floatHandler(site.SetBufferStartSoc, site.GetBufferStartSoc)},
+		"batterydischargecontrol": {[]string{"POST", "OPTIONS"}, "/batterydischargecontrol/{value:[a-z]+}", boolHandler(site.SetBatteryDischargeControl, site.GetBatteryDischargeControl)},
+		"prioritysoc":             {[]string{"POST", "OPTIONS"}, "/prioritysoc/{value:[0-9.]+}", floatHandler(site.SetPrioritySoc, site.GetPrioritySoc)},
+		"residualpower":           {[]string{"POST", "OPTIONS"}, "/residualpower/{value:[-0-9.]+}", floatHandler(site.SetResidualPower, site.GetResidualPower)},
+		"smartcost":               {[]string{"POST", "OPTIONS"}, "/smartcostlimit/{value:[-0-9.]+}", floatHandler(site.SetSmartCostLimit, site.GetSmartCostLimit)},
+		"tariff":                  {[]string{"GET"}, "/tariff/{tariff:[a-z]+}", tariffHandler(site)},
+		"sessions":                {[]string{"GET"}, "/sessions", sessionHandler},
+		"session1":                {[]string{"PUT", "OPTIONS"}, "/session/{id:[0-9]+}", updateSessionHandler},
+		"session2":                {[]string{"DELETE", "OPTIONS"}, "/session/{id:[0-9]+}", deleteSessionHandler},
+		"telemetry":               {[]string{"GET"}, "/settings/telemetry", boolGetHandler(telemetry.Enabled)},
+		"telemetry2":              {[]string{"POST", "OPTIONS"}, "/settings/telemetry/{value:[a-z]+}", boolHandler(telemetry.Enable, telemetry.Enabled)},
 	}
 
 	for _, r := range routes {
diff --git a/server/http_handler.go b/server/http_handler.go
index 814b0f4c..0cd2753f 100644
--- a/server/http_handler.go
+++ b/server/http_handler.go
@@ -125,14 +125,12 @@ func boolHandler(set func(bool) error, get func() bool) http.HandlerFunc {
 		vars := mux.Vars(r)
 
 		val, err := strconv.ParseBool(vars["value"])
-		if err != nil {
-			jsonError(w, http.StatusBadRequest, err)
-			return
+		if err == nil {
+			err = set(val)
 		}
 
-		err = set(val)
 		if err != nil {
-			jsonError(w, http.StatusNotAcceptable, err)
+			jsonError(w, http.StatusBadRequest, err)
 			return
 		}
 


Test patch:
diff --git a/core/site_battery_test.go b/core/site_battery_test.go
index 7938ba73..5e4623a8 100644
--- a/core/site_battery_test.go
+++ b/core/site_battery_test.go
@@ -10,7 +10,7 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
-func TestBatteryDischarge(t *testing.T) {
+func TestDetermineBatteryMode(t *testing.T) {
 	ctrl := gomock.NewController(t)
 
 	tcs := []struct {
@@ -19,32 +19,21 @@ func TestBatteryDischarge(t *testing.T) {
 		expBatMode   api.BatteryMode
 		mode         api.ChargeMode
 	}{
-		{api.StatusB, false, api.BatteryNormal, api.ModeOff},   // mode off -> bat enabled
-		{api.StatusB, false, api.BatteryNormal, api.ModeNow},   // mode now, not charging -> bat enabled
-		{api.StatusC, false, api.BatteryHold, api.ModeNow},     // mode now, charging -> bat disabled
-		{api.StatusB, false, api.BatteryNormal, api.ModeMinPV}, // mode minPV, not charging -> bat enabled
-		{api.StatusC, false, api.BatteryNormal, api.ModeMinPV}, // mode minPV, charging -> bat enabled
-		{api.StatusB, false, api.BatteryNormal, api.ModePV},    // mode PV, not charging -> bat enabled
-		{api.StatusC, false, api.BatteryNormal, api.ModePV},    // mode PV, charging, no planner -> bat enabled
-		{api.StatusC, true, api.BatteryHold, api.ModePV},       // mode PV, charging, planner active -> bat disabled
+		{api.StatusB, false, api.BatteryNormal, api.ModeOff},   // mode off -> bat normal
+		{api.StatusB, false, api.BatteryNormal, api.ModeNow},   // mode now, not charging -> bat normal
+		{api.StatusC, false, api.BatteryHold, api.ModeNow},     // mode now, charging -> bat hold
+		{api.StatusB, false, api.BatteryNormal, api.ModeMinPV}, // mode minPV, not charging -> bat normal
+		{api.StatusC, false, api.BatteryNormal, api.ModeMinPV}, // mode minPV, charging -> bat normal
+		{api.StatusB, false, api.BatteryNormal, api.ModePV},    // mode PV, not charging -> bat normal
+		{api.StatusC, false, api.BatteryNormal, api.ModePV},    // mode PV, charging, no planner -> bat normal
+		{api.StatusC, true, api.BatteryHold, api.ModePV},       // mode PV, charging, planner active -> bat hold
 	}
 
 	log := util.NewLogger("foo")
 
 	for _, tc := range tcs {
-		batCtrl := struct {
-			*api.MockBatteryController
-			*api.MockMeter
-		}{
-			api.NewMockBatteryController(ctrl),
-			api.NewMockMeter(ctrl),
-		}
-		batCtrl.MockBatteryController.EXPECT().SetBatteryMode(tc.expBatMode).Times(1)
-
 		s := &Site{
-			log:                     log,
-			BatteryDischargeControl: true,
-			batteryMeters:           []api.Meter{batCtrl},
+			log: log,
 		}
 
 		lp := loadpoint.NewMockAPI(ctrl)
@@ -53,13 +42,16 @@ func TestBatteryDischarge(t *testing.T) {
 		lp.EXPECT().GetPlanActive().Return(tc.planActive).AnyTimes()
 
 		loadpoints := []loadpoint.API{lp}
-		s.updateBatteryMode(loadpoints)
-		assert.Equal(t, tc.expBatMode, s.getBatteryMode(), tc)
+
+		mode := s.determineBatteryMode(loadpoints)
+		assert.Equal(t, tc.expBatMode, mode, tc)
 	}
 }
 
-// test that BatteryControllers are only called if batterymode changes
-func TestBatteryModeNoUpdate(t *testing.T) {
+func TestUpdateBatteryMode(t *testing.T) {
+
+	expBatMode := api.BatteryHold
+
 	ctrl := gomock.NewController(t)
 
 	batCtrl := struct {
@@ -69,28 +61,15 @@ func TestBatteryModeNoUpdate(t *testing.T) {
 		api.NewMockBatteryController(ctrl),
 		api.NewMockMeter(ctrl),
 	}
-	batCtrl.MockBatteryController.EXPECT().SetBatteryMode(api.BatteryHold).Times(1)
-
-	lp := loadpoint.NewMockAPI(ctrl)
-	lp.EXPECT().GetStatus().Return(api.StatusC).Times(2)
-	lp.EXPECT().GetMode().Return(api.ModeNow).Times(2)
-	lp.EXPECT().GetPlanActive().Times(0)
-	loadpoints := []loadpoint.API{lp}
+	batCtrl.MockBatteryController.EXPECT().SetBatteryMode(expBatMode).Times(1)
 
 	s := &Site{
-		batteryMode:             api.BatteryNormal,
-		batteryMeters:           []api.Meter{batCtrl},
-		BatteryDischargeControl: true,
-		log:                     util.NewLogger("foo"),
+		log:           util.NewLogger("foo"),
+		batteryMeters: []api.Meter{batCtrl},
+		batteryMode:   api.BatteryNormal,
 	}
 
-	s.updateBatteryMode(loadpoints) // first call should call BatteryController
-	s.updateBatteryMode(loadpoints) // this one should not
-
-	// adjust mocks to simulate charge stop, should cause batMode udpate
-	lp.EXPECT().GetStatus().Return(api.StatusB).Times(1)
-	lp.EXPECT().GetMode().Return(api.ModeNow).Times(0)
-	batCtrl.MockBatteryController.EXPECT().SetBatteryMode(api.BatteryNormal).Times(1)
-
-	s.updateBatteryMode(loadpoints) // this one should have updated again
+	err := s.updateBatteryMode(expBatMode)
+	assert.NoError(t, err)
+	assert.Equal(t, expBatMode, s.GetBatteryMode())
 }