From 3bb943cbe53524427e37e9cea1221a099b3becd4 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Mon, 13 Jul 2026 14:12:33 +0200 Subject: [PATCH] Loadpoint: add min temperature for heating devices (#31749) --- assets/js/components/Loadpoints/Loadpoint.vue | 1 + .../components/Loadpoints/SettingsModal.vue | 59 +++++++++++++++++++ assets/js/components/MaterialIcon/MinTemp.vue | 18 ++++++ assets/js/components/Vehicles/Status.vue | 34 +++++++---- .../js/components/Vehicles/Vehicle.stories.ts | 27 +++++++++ assets/js/components/Vehicles/Vehicle.vue | 3 +- assets/js/types/evcc.ts | 2 + core/keys/loadpoint.go | 3 +- core/loadpoint.go | 21 ++++--- core/loadpoint/api.go | 4 ++ core/loadpoint/mock.go | 26 ++++++++ core/loadpoint_api.go | 28 +++++++++ core/loadpoint_effective.go | 20 +++++++ core/loadpoint_effective_test.go | 36 +++++++++++ i18n/de.json | 6 ++ i18n/en.json | 6 ++ server/http.go | 1 + server/mcp/openapi.json | 41 +++++++++++++ server/mcp/openapi.md | 22 +++++++ server/mqtt.go | 1 + server/openapi.yaml | 27 +++++++++ tests/heating.evcc.yaml | 2 +- tests/heating.spec.ts | 25 +++++++- 23 files changed, 386 insertions(+), 27 deletions(-) create mode 100644 assets/js/components/MaterialIcon/MinTemp.vue diff --git a/assets/js/components/Loadpoints/Loadpoint.vue b/assets/js/components/Loadpoints/Loadpoint.vue index a34c453b2..b3e280062 100644 --- a/assets/js/components/Loadpoints/Loadpoint.vue +++ b/assets/js/components/Loadpoints/Loadpoint.vue @@ -145,6 +145,7 @@ export default defineComponent({ title: String, mode: String as PropType, effectiveLimitSoc: Number, + effectiveMinSoc: Number, limitEnergy: Number, remoteDisabled: String, remoteDisabledSource: String, diff --git a/assets/js/components/Loadpoints/SettingsModal.vue b/assets/js/components/Loadpoints/SettingsModal.vue index cc441bd50..b538da28a 100644 --- a/assets/js/components/Loadpoints/SettingsModal.vue +++ b/assets/js/components/Loadpoints/SettingsModal.vue @@ -37,6 +37,39 @@ class="mt-2" @batteryboostlimit-updated="setBatteryBoostLimit" /> +
+ {{ $t("main.loadpointSettings.heating") }} +
+
+ +
+ +
+
+ + {{ + $t("main.loadpointSettings.minTemp.description", [ + selectedMinTemp ? fmtTemperature(selectedMinTemp) : "x °C", + ]) + }} + +
+
{{ $t("main.loadpointSettings.currents") }}
@@ -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 | 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); }, diff --git a/assets/js/components/MaterialIcon/MinTemp.vue b/assets/js/components/MaterialIcon/MinTemp.vue new file mode 100644 index 000000000..b9dc244ca --- /dev/null +++ b/assets/js/components/MaterialIcon/MinTemp.vue @@ -0,0 +1,18 @@ + + + diff --git a/assets/js/components/Vehicles/Status.vue b/assets/js/components/Vehicles/Status.vue index 2557af6fa..a60890799 100644 --- a/assets/js/components/Vehicles/Status.vue +++ b/assets/js/components/Vehicles/Status.vue @@ -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: diff --git a/assets/js/components/Vehicles/Vehicle.stories.ts b/assets/js/components/Vehicles/Vehicle.stories.ts index 4b89c6fb5..c3ed5ae06 100644 --- a/assets/js/components/Vehicles/Vehicle.stories.ts +++ b/assets/js/components/Vehicles/Vehicle.stories.ts @@ -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, diff --git a/assets/js/components/Vehicles/Vehicle.vue b/assets/js/components/Vehicles/Vehicle.vue index d80b29a87..ae66117a9 100644 --- a/assets/js/components/Vehicles/Vehicle.vue +++ b/assets/js/components/Vehicles/Vehicle.vue @@ -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, @@ -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); diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index 351e6f708..6abbfefe5 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -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; diff --git a/core/keys/loadpoint.go b/core/keys/loadpoint.go index 5c6fa4706..e6ebd5f5a 100644 --- a/core/keys/loadpoint.go +++ b/core/keys/loadpoint.go @@ -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 diff --git a/core/loadpoint.go b/core/loadpoint.go index 69786a870..d874701e6 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -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 } diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go index 71051f4db..6eea8c6a9 100644 --- a/core/loadpoint/api.go +++ b/core/loadpoint/api.go @@ -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 diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go index 3f43e60a4..c15927f74 100644 --- a/core/loadpoint/mock.go +++ b/core/loadpoint/mock.go @@ -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() diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go index dec8d42c2..14d84ef04 100644 --- a/core/loadpoint_api.go +++ b/core/loadpoint_api.go @@ -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() diff --git a/core/loadpoint_effective.go b/core/loadpoint_effective.go index 4069ce734..370cb166a 100644 --- a/core/loadpoint_effective.go +++ b/core/loadpoint_effective.go @@ -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() diff --git a/core/loadpoint_effective_test.go b/core/loadpoint_effective_test.go index 07727e5ee..e35af5882 100644 --- a/core/loadpoint_effective_test.go +++ b/core/loadpoint_effective_test.go @@ -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 diff --git a/i18n/de.json b/i18n/de.json index fd9624fea..a7f0d85c2 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -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?", diff --git a/i18n/en.json b/i18n/en.json index b72017aab..0b5de1262 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -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?", diff --git a/server/http.go b/server/http.go index 1fcae578e..5eac9a080 100644 --- a/server/http.go +++ b/server/http.go @@ -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)}, diff --git a/server/mcp/openapi.json b/server/mcp/openapi.json index 905ff84a4..4e68e9b0e 100644 --- a/server/mcp/openapi.json +++ b/server/mcp/openapi.json @@ -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.", diff --git a/server/mcp/openapi.md b/server/mcp/openapi.md index 40aba839f..e0e0e37fa 100644 --- a/server/mcp/openapi.md +++ b/server/mcp/openapi.md @@ -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. diff --git a/server/mqtt.go b/server/mqtt.go index c3f94298f..5b9af3f8e 100644 --- a/server/mqtt.go +++ b/server/mqtt.go @@ -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)}, diff --git a/server/openapi.yaml b/server/openapi.yaml index 678961262..c56c9b54f 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -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 diff --git a/tests/heating.evcc.yaml b/tests/heating.evcc.yaml index 83b6ef4c0..d70bfba17 100644 --- a/tests/heating.evcc.yaml +++ b/tests/heating.evcc.yaml @@ -47,5 +47,5 @@ chargers: soc: source: js script: | - 55 + 40 features: [integrateddevice, heating, switchdevice] diff --git a/tests/heating.spec.ts b/tests/heating.spec.ts index da6e513d6..c8233429b 100644 --- a/tests/heating.spec.ts +++ b/tests/heating.spec.ts @@ -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();