Loadpoint: add min temperature for heating devices (#31749)

This commit is contained in:
Michael Geers 2026-07-13 14:12:33 +02:00 • committed by GitHub
parent 857a93f83e
commit 3bb943cbe5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 386 additions and 27 deletions

View file

@ -145,6 +145,7 @@ export default defineComponent({
title: String,
mode: String as PropType<CHARGE_MODE>,
effectiveLimitSoc: Number,
effectiveMinSoc: Number,
limitEnergy: Number,
remoteDisabled: String,
remoteDisabledSource: String,

View file

@ -37,6 +37,39 @@
class="mt-2"
@batteryboostlimit-updated="setBatteryBoostLimit"
/>
<h6 v-if="heating">
{{ $t("main.loadpointSettings.heating") }}
</h6>
<div v-if="heating" class="mb-3 row">
<label :for="formId('mintemp')" class="col-sm-4 col-form-label pt-0 pt-sm-2">
{{ $t("main.loadpointSettings.minTemp.label") }}
</label>
<div class="col-sm-8 col-lg-4 pe-0 d-flex align-items-center">
<select
:id="formId('mintemp')"
v-model.number="selectedMinTemp"
class="form-select form-select-sm"
@change="setMinTemp"
>
<option
v-for="{ value, name } in minTempOptions"
:key="value"
:value="value"
>
{{ name }}
</option>
</select>
</div>
<div class="col-sm-8 offset-sm-4 mt-1">
<small class="text-muted">
{{
$t("main.loadpointSettings.minTemp.description", [
selectedMinTemp ? fmtTemperature(selectedMinTemp) : "x °C",
])
}}
</small>
</div>
</div>
<h6>
{{ $t("main.loadpointSettings.currents") }}
</h6>
@ -180,6 +213,7 @@ export default defineComponent({
id: undefined as string | undefined,
selectedMaxCurrent: undefined as number | undefined,
selectedMinCurrent: undefined as number | undefined,
selectedMinTemp: undefined as number | undefined,
selectedPhases: undefined as number | undefined,
isModalVisible: false,
};
@ -197,6 +231,24 @@ export default defineComponent({
switchDevice() {
return this.loadpoint?.chargerFeatureSwitchDevice;
},
heating() {
return this.loadpoint?.chargerFeatureHeating;
},
minTemp() {
// stored as loadpoint minSoc, interpreted as temperature for heating devices
return this.loadpoint?.minSoc;
},
minTempOptions() {
let { minTemp = 0, maxTemp = 100 } = this.loadpoint?.ui ?? {};
if (minTemp >= maxTemp) {
minTemp = 0;
maxTemp = 100;
}
return [0, ...range(Math.max(minTemp, 1), maxTemp, 1)].map((value) => ({
value,
name: value === 0 ? "---" : this.fmtTemperature(value),
}));
},
batteryBoostLimit() {
return this.loadpoint?.batteryBoostLimit;
},
@ -252,6 +304,9 @@ export default defineComponent({
minCurrent(value) {
this.selectedMinCurrent = value;
},
minTemp(value) {
this.selectedMinTemp = value;
},
phasesConfigured(value) {
this.selectedPhases = value;
},
@ -262,6 +317,7 @@ export default defineComponent({
this.selectedPhases = this.phasesConfigured;
this.selectedMaxCurrent = this.maxCurrent;
this.selectedMinCurrent = this.minCurrent;
this.selectedMinTemp = this.minTemp;
const modalRef = this.$refs["modal"] as InstanceType<typeof GenericModal> | undefined;
modalRef?.open();
},
@ -280,6 +336,9 @@ export default defineComponent({
setMinCurrent() {
api.post(this.apiPath("mincurrent") + "/" + this.selectedMinCurrent);
},
setMinTemp() {
api.post(this.apiPath("mintemp") + "/" + this.selectedMinTemp);
},
setPhasesConfigured() {
api.post(this.apiPath("phases") + "/" + this.selectedPhases);
},

View file

@ -0,0 +1,18 @@
<template>
<svg :style="svgStyle" viewBox="0 0 24 24">
<path
d="M20 10q-.425 0-.712-.288T19 9t.288-.712T20 8t.713.288T21 9t-.288.713T20 10m-10 8.4l-1.925 1.925q-.275.275-.687.275t-.688-.3q-.3-.275-.3-.687t.3-.713l3.3-3.3V14H8.4l-3.325 3.325q-.275.275-.687.275t-.688-.3q-.3-.275-.3-.687t.3-.713L5.6 14H2.975q-.425 0-.7-.288T2 13t.288-.712T3 12h2.6l-1.925-1.9Q3.4 9.825 3.4 9.413t.3-.713q.275-.275.688-.275T5.1 8.7L8.4 12H10v-1.6L6.675 7.1Q6.4 6.825 6.4 6.413t.3-.713q.275-.275.688-.275T8.1 5.7L10 7.6V5q0-.425.288-.712T11 4t.713.288T12 5v2.6l1.9-1.9q.275-.275.688-.275t.712.275q.275.3.275.712T15.3 7.1L12 10.4V12h7q.425 0 .713.288T20 13t-.288.713T19 14h-2.6l1.9 1.925q.275.275.275.688t-.275.687q-.3.3-.712.3t-.688-.3L13.6 14H12v1.6l3.3 3.325q.275.275.275.688t-.275.687q-.3.3-.712.3t-.688-.3L12 18.4v2.625q0 .425-.288.7T11 22t-.712-.288T10 21zM20 7q-.425 0-.712-.287T19 6V3q0-.425.288-.712T20 2t.713.288T21 3v3.025q0 .425-.288.7T20 7"
fill="currentColor"
/>
</svg>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import icon from "@/mixins/icon";
export default defineComponent({
name: "MinTemp",
mixins: [icon],
});
</script>

View file

@ -80,6 +80,7 @@ import RfidWaitIcon from "../MaterialIcon/RfidWait.vue";
import SunDownIcon from "../MaterialIcon/SunDown.vue";
import SunUpIcon from "../MaterialIcon/SunUp.vue";
import TempLimitIcon from "../MaterialIcon/TempLimit.vue";
import MinTempIcon from "../MaterialIcon/MinTemp.vue";
import VehicleLimitIcon from "../MaterialIcon/VehicleLimit.vue";
import VehicleLimitReachedIcon from "../MaterialIcon/VehicleLimitReached.vue";
import VehicleLimitWarningIcon from "../MaterialIcon/VehicleLimitWarning.vue";
@ -260,6 +261,26 @@ export default defineComponent({
testId: "vehicle-status-phasetimer",
tabular: true,
},
{
id: "minSoc",
visible: this.connected && this.minSocNotReached,
content: this.heating
? this.fmtTemperature(this.minSoc)
: this.fmtPercentage(this.minSoc),
tooltipContent: this.heating
? this.$t("main.heatingStatus.minTemp", {
temp: this.fmtTemperature(this.minSoc),
})
: t("minCharge", {
soc: this.fmtPercentage(this.minSoc),
}),
iconComponent: this.heating ? MinTempIcon : VehicleMinSocIcon,
itemClass: "text-danger text-decoration-underline",
testId: "vehicle-status-minsoc",
clickable: true,
clickHandler: () =>
this.heating ? this.openLoadpointSettings() : this.openMinSocSettings(),
},
{
id: "tempLimit",
visible: this.heating && this.vehicleLimitSoc > 0,
@ -268,19 +289,6 @@ export default defineComponent({
iconComponent: TempLimitIcon,
testId: "vehicle-status-limit",
},
{
id: "minSoc",
visible: !this.heating && this.connected && this.minSocNotReached,
content: this.fmtPercentage(this.minSoc),
tooltipContent: t("minCharge", {
soc: this.fmtPercentage(this.minSoc),
}),
iconComponent: VehicleMinSocIcon,
itemClass: "text-danger text-decoration-underline",
testId: "vehicle-status-minsoc",
clickable: true,
clickHandler: () => this.openMinSocSettings(),
},
{
id: "vehicleLimit",
visible:

View file

@ -162,6 +162,33 @@ VehicleLimitReached.args = {
vehicleSoc: 80,
};
export const MinSocCharging = Template.bind({});
MinSocCharging.args = {
...baseState,
enabled: true,
charging: true,
vehicleSoc: 17.3,
vehicleRange: 92,
minSocNotReached: true,
effectiveMinSoc: 30,
};
export const HeatingMinTemp = Template.bind({});
HeatingMinTemp.args = {
...baseState,
vehicle: { ...baseState.vehicle, title: "Warmwasser", icon: "waterheater" },
heating: true,
integratedDevice: true,
enabled: true,
charging: true,
vehicleSoc: 38,
vehicleRange: 0,
effectiveLimitSoc: 60,
minSocNotReached: true,
effectiveMinSoc: 40,
ui: { minTemp: 35, maxTemp: 70 },
};
export const TargetChargePlanned = Template.bind({});
TargetChargePlanned.args = {
...baseState,

View file

@ -125,6 +125,7 @@ export default defineComponent({
connected: Boolean,
currency: String,
effectiveLimitSoc: Number,
effectiveMinSoc: { type: Number, default: 0 },
effectivePlanSoc: Number,
effectivePlanTime: String,
effectivePlanStrategy: Object as PropType<PlanStrategy>,
@ -209,7 +210,7 @@ export default defineComponent({
return this.vehicle?.icon || "";
},
minSoc() {
return this.vehicle?.minSoc || 0;
return this.effectiveMinSoc;
},
vehicleSocProps() {
return this.collectProps(Soc);

View file

@ -327,6 +327,7 @@ export interface Loadpoint {
effectiveLimitSoc: number;
effectiveMaxCurrent: number;
effectiveMinCurrent: number;
effectiveMinSoc: number;
effectivePlanId: number;
effectivePlanSoc: number;
effectivePlanTime: string | null;
@ -339,6 +340,7 @@ export interface Loadpoint {
limitSoc: number;
maxCurrent: number;
minCurrent: number;
minSoc: number;
minSocNotReached: boolean;
mode: CHARGE_MODE;
offeredCurrent: number;

View file

@ -13,7 +13,7 @@ const (
Priority = "priority" // priority
MinCurrent = "minCurrent" // min current
MaxCurrent = "maxCurrent" // max current
MinSoc = "minSoc" // min soc
MinSoc = "minSoc" // min soc (heating: min temperature)
MinSocNotReached = "minSocNotReached" // min soc not reached
LimitSoc = "limitSoc" // limit soc
LimitEnergy = "limitEnergy" // limit energy
@ -65,6 +65,7 @@ const (
EffectiveMinCurrent = "effectiveMinCurrent" // effective min current
EffectiveMaxCurrent = "effectiveMaxCurrent" // effective max current
EffectiveMinSoc = "effectiveMinSoc" // effective min soc
EffectiveLimitSoc = "effectiveLimitSoc" // effective limit soc
EffectivePlanStrategy = "effectivePlanStrategy" // effective plan strategy

View file

@ -24,7 +24,6 @@ import (
"github.com/evcc-io/evcc/core/settings"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/core/soc"
"github.com/evcc-io/evcc/core/vehicle"
"github.com/evcc-io/evcc/core/wrapper"
"github.com/evcc-io/evcc/messenger"
"github.com/evcc-io/evcc/util"
@ -118,6 +117,7 @@ type Loadpoint struct {
phasesConfigured int // Charger configured phase mode 0/1/3
limitSoc int // Session limit for soc
limitEnergy float64 // Session limit for energy
minSoc int // Forced charging below this soc (heating: temperature), 0=disabled
smartCostLimit *float64 // always charge if consumption cost is below this value
smartFeedInPriorityLimit *float64 // prevent charging if feed-in cost is above this value
batteryBoost int // battery boost state
@ -361,6 +361,9 @@ func (lp *Loadpoint) restoreSettings() {
if v, err := lp.settings.Int(keys.LimitSoc); err == nil && v > 0 {
lp.setLimitSoc(int(v))
}
if v, err := lp.settings.Int(keys.MinSoc); err == nil && v > 0 {
lp.setMinSoc(int(v))
}
if v, err := lp.settings.Float(keys.LimitEnergy); err == nil && v > 0 {
lp.setLimitEnergy(v)
}
@ -751,6 +754,7 @@ func (lp *Loadpoint) Prepare(site site.API, uiChan chan<- util.Param, pushChan c
lp.publish(keys.PlanStrategy, lp.planStrategy)
lp.publish(keys.LimitSoc, lp.limitSoc)
lp.publish(keys.LimitEnergy, lp.limitEnergy)
lp.publish(keys.MinSoc, lp.minSoc)
// planner
lp.publish(keys.PlanActive, lp.planActive)
@ -1091,15 +1095,9 @@ func (lp *Loadpoint) LimitSocReached() bool {
return limit > 0 && limit < 100 && lp.vehicleSoc >= float64(limit)
}
// minSocNotReached checks if minimum is configured and not reached.
// If vehicle is not configured this will always return false
// minSocNotReached checks if minimum is configured and not reached
func (lp *Loadpoint) minSocNotReached() bool {
v := lp.GetVehicle()
if v == nil {
return false
}
minSoc := vehicle.Settings(lp.log, v).GetMinSoc()
minSoc := lp.effectiveMinSoc()
if minSoc == 0 {
return false
}
@ -1112,6 +1110,11 @@ func (lp *Loadpoint) minSocNotReached() bool {
return active
}
v := lp.GetVehicle()
if v == nil {
return false
}
minEnergy := v.Capacity() * float64(minSoc) * 10 / soc.ChargeEfficiency
return minEnergy > 0 && lp.getChargedEnergy() < minEnergy
}

View file

@ -91,6 +91,10 @@ type API interface {
GetLimitEnergy() float64
// SetLimitEnergy sets the session limit energy
SetLimitEnergy(energy float64)
// GetMinSoc returns the loadpoint min soc (heating: min temperature)
GetMinSoc() int
// SetMinSoc sets the loadpoint min soc (heating: min temperature)
SetMinSoc(soc int)
//
// effective values

View file

@ -431,6 +431,20 @@ func (mr *MockAPIMockRecorder) GetMinCurrent() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMinCurrent", reflect.TypeOf((*MockAPI)(nil).GetMinCurrent))
}
// GetMinSoc mocks base method.
func (m *MockAPI) GetMinSoc() int {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetMinSoc")
ret0, _ := ret[0].(int)
return ret0
}
// GetMinSoc indicates an expected call of GetMinSoc.
func (mr *MockAPIMockRecorder) GetMinSoc() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMinSoc", reflect.TypeOf((*MockAPI)(nil).GetMinSoc))
}
// GetMode mocks base method.
func (m *MockAPI) GetMode() api.ChargeMode {
m.ctrl.T.Helper()
@ -939,6 +953,18 @@ func (mr *MockAPIMockRecorder) SetMinCurrent(arg0 any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetMinCurrent", reflect.TypeOf((*MockAPI)(nil).SetMinCurrent), arg0)
}
// SetMinSoc mocks base method.
func (m *MockAPI) SetMinSoc(soc int) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "SetMinSoc", soc)
}
// SetMinSoc indicates an expected call of SetMinSoc.
func (mr *MockAPIMockRecorder) SetMinSoc(soc any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetMinSoc", reflect.TypeOf((*MockAPI)(nil).SetMinSoc), soc)
}
// SetMode mocks base method.
func (m *MockAPI) SetMode(arg0 api.ChargeMode) {
m.ctrl.T.Helper()

View file

@ -322,6 +322,34 @@ func (lp *Loadpoint) SetLimitSoc(soc int) {
}
}
// GetMinSoc returns the loadpoint min soc (heating: min temperature)
func (lp *Loadpoint) GetMinSoc() int {
lp.RLock()
defer lp.RUnlock()
return lp.minSoc
}
// setMinSoc sets the loadpoint min soc (no mutex)
func (lp *Loadpoint) setMinSoc(soc int) {
lp.minSoc = soc
lp.publish(keys.MinSoc, soc)
lp.settings.SetInt(keys.MinSoc, int64(soc))
}
// SetMinSoc sets the loadpoint min soc (heating: min temperature)
func (lp *Loadpoint) SetMinSoc(soc int) {
lp.Lock()
defer lp.Unlock()
lp.log.DEBUG.Println("set min soc:", soc)
// apply immediately
if lp.minSoc != soc {
lp.setMinSoc(soc)
lp.requestUpdate()
}
}
// GetLimitEnergy returns the session limit energy
func (lp *Loadpoint) GetLimitEnergy() float64 {
lp.RLock()

View file

@ -20,6 +20,7 @@ func (lp *Loadpoint) PublishEffectiveValues() {
lp.publish(keys.EffectivePlanStrategy, lp.EffectivePlanStrategy())
lp.publish(keys.EffectiveMinCurrent, lp.effectiveMinCurrent())
lp.publish(keys.EffectiveMaxCurrent, lp.effectiveMaxCurrent())
lp.publish(keys.EffectiveMinSoc, lp.EffectiveMinSoc())
lp.publish(keys.EffectiveLimitSoc, lp.EffectiveLimitSoc())
}
@ -216,6 +217,25 @@ func (lp *Loadpoint) effectiveMaxCurrent() float64 {
return maxCurrent
}
// EffectiveMinSoc returns the effective min soc (heating: min temperature)
func (lp *Loadpoint) EffectiveMinSoc() int {
lp.RLock()
defer lp.RUnlock()
return lp.effectiveMinSoc()
}
// effectiveMinSoc returns the effective min soc (heating: min temperature)
func (lp *Loadpoint) effectiveMinSoc() int {
minSoc := lp.minSoc
// loadpoint and vehicle min soc are independent limits- honor both
if v := lp.GetVehicle(); v != nil {
minSoc = max(minSoc, vehicle.Settings(lp.log, v).GetMinSoc())
}
return minSoc
}
// EffectiveLimitSoc returns the effective session limit soc
func (lp *Loadpoint) EffectiveLimitSoc() int {
lp.RLock()

View file

@ -6,7 +6,10 @@ import (
"github.com/benbjohnson/clock"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
@ -17,6 +20,39 @@ func TestEffectiveLimitSoc(t *testing.T) {
assert.Equal(t, 100, lp.effectiveLimitSoc())
}
func TestEffectiveMinSoc(t *testing.T) {
config.Reset()
t.Cleanup(config.Reset)
for _, tc := range []struct {
loadpoint, vehicle, expected int
}{
{0, 0, 0},
{10, 0, 10}, // loadpoint only
{0, 20, 20}, // vehicle only
{10, 20, 20}, // vehicle wins
{20, 10, 20}, // loadpoint wins
} {
t.Logf("%+v", tc)
config.Reset()
ctrl := gomock.NewController(t)
v := api.NewMockVehicle(ctrl)
const name = "vehicle"
require.NoError(t, config.Vehicles().Add(
config.NewStaticDevice(config.Named{Name: name}, api.Vehicle(v)),
))
settings.SetInt("vehicle."+name+"."+keys.MinSoc, int64(tc.vehicle))
lp := NewLoadpoint(util.NewLogger("foo"), nil)
lp.vehicle = v
lp.minSoc = tc.loadpoint
assert.Equal(t, tc.expected, lp.effectiveMinSoc())
}
}
func TestEffectiveMinMaxCurrent(t *testing.T) {
tc := []struct {
chargerMin, chargerMax float64

View file

@ -1308,6 +1308,7 @@
"heatingStatus": {
"charging": "Heize …",
"connected": "Standby.",
"minTemp": "Mindestheizung auf {temp}.",
"suggestionStartTooltip": "Der Optimizer empfiehlt, jetzt zu heizen.",
"suggestionStopTooltip": "Der Optimizer empfiehlt, das Heizen zu pausieren.",
"vehicleLimit": "Heizungslimit",
@ -1378,6 +1379,7 @@
"batteryUsage": "Hausbatterie",
"currents": "Ladestrom",
"default": "default",
"heating": "Heizen",
"limitSoc": {
"description": "Dieses Ladelimit wird verwendet, wenn das Fahrzeug angeschlossen wird.",
"label": "Standard Ladelimit"
@ -1392,6 +1394,10 @@
"description": "Fahrzeug wird im PV-Modus „Schnell“ auf {0} geladen. Danach weiter mit PV-Überschuss. Nützlich, um auch an dunklen Tagen eine Mindestreichweite zu gewährleisten.",
"label": "Min. Ladung %"
},
"minTemp": {
"description": "Unter {0} wird im PV-Modus auch mit Batterie- und Netzstrom geheizt, um diese Temperatur zu halten.",
"label": "Min. Temperatur"
},
"phasesConfigured": {
"label": "Phasen",
"no1p3pSupport": "Wie ist deine Wallbox angeschlossen?",

View file

@ -1308,6 +1308,7 @@
"heatingStatus": {
"charging": "Heating…",
"connected": "Standby.",
"minTemp": "Minimum heating to {temp}.",
"suggestionStartTooltip": "Optimizer recommends heating now.",
"suggestionStopTooltip": "Optimizer recommends pausing the heating.",
"vehicleLimit": "Heater limit",
@ -1378,6 +1379,7 @@
"batteryUsage": "Home Battery",
"currents": "Charging Current",
"default": "default",
"heating": "Heating",
"limitSoc": {
"description": "Charging limit that is used when this vehicle is connected.",
"label": "Default limit"
@ -1392,6 +1394,10 @@
"description": "The vehicle gets „fast” charged to {0} in solar mode. Then continues with solar surplus. Useful to ensure a minimum range even for darker days.",
"label": "Min. charge %"
},
"minTemp": {
"description": "Below {0} battery and grid power are also used in solar mode to maintain this temperature.",
"label": "Min. temperature"
},
"phasesConfigured": {
"label": "Phases",
"no1p3pSupport": "How is your charger connected?",

View file

@ -195,6 +195,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API) {
routes := map[string]route{
"mode": {"POST", "/mode/{value:[a-z]+}", handler(eapi.ChargeModeString, pass(lp.SetMode), lp.GetMode)},
"limitsoc": {"POST", "/limitsoc/{value:[0-9]+}", intHandler(pass(lp.SetLimitSoc), lp.GetLimitSoc)},
"mintemp": {"POST", "/mintemp/{value:[0-9]+}", intHandler(pass(lp.SetMinSoc), lp.GetMinSoc)},
"limitenergy": {"POST", "/limitenergy/{value:[0-9.]+}", floatHandler(pass(lp.SetLimitEnergy), lp.GetLimitEnergy)},
"mincurrent": {"POST", "/mincurrent/{value:[0-9.]+}", floatHandler(lp.SetMinCurrent, lp.GetMinCurrent)},
"maxcurrent": {"POST", "/maxcurrent/{value:[0-9.]+}", floatHandler(lp.SetMaxCurrent, lp.GetMaxCurrent)},

View file

@ -140,6 +140,15 @@
"$ref": "#/components/schemas/Soc"
}
},
"temp": {
"description": "Temperature in °C",
"in": "path",
"name": "temp",
"required": true,
"schema": {
"$ref": "#/components/schemas/Temp"
}
},
"threshold": {
"description": "Power in W",
"in": "path",
@ -719,6 +728,12 @@
},
"type": "object"
},
"Temp": {
"description": "Temperature in °C",
"example": 50,
"minimum": 0,
"type": "number"
},
"Timestamp": {
"description": "Timestamp in RFC3339 format",
"format": "date-time",
@ -1727,6 +1742,32 @@
]
}
},
"/loadpoints/{id}/mintemp/{temp}": {
"post": {
"description": "Sets the minimum temperature for heating devices. The device is heated as fast as possible while below this value. Set to 0 to disable.",
"externalDocs": {
"url": "https://docs.evcc.io/en/docs/features/limit"
},
"operationId": "setLoadpointMinTemp",
"parameters": [
{
"$ref": "#/components/parameters/id"
},
{
"$ref": "#/components/parameters/temp"
}
],
"responses": {
"200": {
"$ref": "#/components/responses/NumberResult"
}
},
"summary": "Update minimum temperature",
"tags": [
"loadpoints"
]
}
},
"/loadpoints/{id}/mode/{mode}": {
"post": {
"description": "Changes the charging behavior of the loadpoint.",

View file

@ -840,6 +840,28 @@ call setLoadpointMinCurrent {
}
```
## setLoadpointMinTemp
Sets the minimum temperature for heating devices. The device is heated as fast as possible while below this value. Set to 0 to disable.
**Tags:** loadpoints
**Arguments:**
| Name | Type | Description |
|------|------|-------------|
| id | integer | Loadpoint index starting at 1 |
| temp | number | Temperature in °C |
**Example call:**
```json
call setLoadpointMinTemp {
"id": 1,
"temp": 50
}
```
## setLoadpointMode
Changes the charging behavior of the loadpoint.

View file

@ -248,6 +248,7 @@ func (m *MQTT) listenLoadpointSetters(topic string, site site.API, lp loadpoint.
{"mode", setterFunc(api.ChargeModeString, pass(lp.SetMode))},
{"phasesConfigured", intSetter(lp.SetPhasesConfigured)},
{"limitSoc", intSetter(pass(lp.SetLimitSoc))},
{"minSoc", intSetter(pass(lp.SetMinSoc))},
{"priority", intSetter(pass(lp.SetPriority))},
{"minCurrent", floatSetter(lp.SetMinCurrent)},
{"maxCurrent", floatSetter(lp.SetMaxCurrent)},

View file

@ -402,6 +402,21 @@ paths:
responses:
"200":
$ref: "#/components/responses/NumberResult"
/loadpoints/{id}/mintemp/{temp}:
post:
operationId: setLoadpointMinTemp
summary: Update minimum temperature
description: "Sets the minimum temperature for heating devices. The device is heated as fast as possible while below this value. Set to 0 to disable."
externalDocs:
url: https://docs.evcc.io/en/docs/features/limit
tags:
- loadpoints
parameters:
- $ref: "#/components/parameters/id"
- $ref: "#/components/parameters/temp"
responses:
"200":
$ref: "#/components/responses/NumberResult"
/loadpoints/{id}/mode/{mode}:
post:
operationId: setLoadpointMode
@ -1678,6 +1693,11 @@ components:
example: 60
minimum: 0
maximum: 100
Temp:
description: Temperature in °C
type: number
example: 50
minimum: 0
State:
description: "The actual state structure is not documented yet. Most values should be self-explanatory. Note: While the overall structure is quite stable, details may change between releases."
type: object
@ -1803,6 +1823,13 @@ components:
required: true
schema:
$ref: "#/components/schemas/Soc"
temp:
name: temp
description: Temperature in °C
in: path
required: true
schema:
$ref: "#/components/schemas/Temp"
timestamp:
name: timestamp
description: Timestamp in RFC3339 format

View file

@ -47,5 +47,5 @@ chargers:
soc:
source: js
script: |
55
40
features: [integrateddevice, heating, switchdevice]

View file

@ -1,6 +1,6 @@
import { test, expect } from "@playwright/test";
import { start, stop, baseUrl } from "./evcc";
import { expectModalVisible } from "./utils";
import { expectModalHidden, expectModalVisible } from "./utils";
test.use({ baseURL: baseUrl() });
@ -17,7 +17,7 @@ test.beforeEach(async ({ page }) => {
test.describe("loadpoint", async () => {
test("initial values", async ({ page }) => {
await expect(page.getByTestId("current-soc")).toContainText("55.0°C");
await expect(page.getByTestId("current-soc")).toContainText("40.0°C");
await expect(page.getByTestId("limit-soc")).toContainText("100.0°C");
});
@ -37,6 +37,27 @@ test.describe("integrated device", async () => {
await expect(mode.getByRole("button", { name: "Min+Solar" })).toHaveCount(0);
});
test("min temperature", async ({ page }) => {
const lp = page.getByTestId("loadpoint").first();
await lp.getByTestId("loadpoint-settings-button").last().click();
const modal = page.getByTestId("loadpoint-settings-modal").first();
await expectModalVisible(modal);
const minTemp = modal.getByLabel("Min. temperature");
await expect(minTemp).toHaveValue("0");
await minTemp.selectOption("50.0°C");
await modal.getByRole("button", { name: "Close" }).click();
await expectModalHidden(modal);
// temp 40 below minimum 50 -> forced heating indicator
await expect(lp.getByTestId("vehicle-status-minsoc")).toContainText("50.0°C");
await page.reload();
await expect(lp.getByTestId("vehicle-status-minsoc")).toContainText("50.0°C");
await lp.getByTestId("loadpoint-settings-button").last().click();
await expectModalVisible(modal);
await expect(modal.getByLabel("Min. temperature")).toHaveValue("50");
});
test("min current and phases but no max current for switch device", async ({ page }) => {
const lp = page.getByTestId("loadpoint").first();
await lp.getByTestId("loadpoint-settings-button").last().click();