Loadpoint: add min temperature for heating devices (#31749)
This commit is contained in:
parent
857a93f83e
commit
3bb943cbe5
23 changed files with 386 additions and 27 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
18
assets/js/components/MaterialIcon/MinTemp.vue
Normal file
18
assets/js/components/MaterialIcon/MinTemp.vue
Normal 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>
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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)},
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -47,5 +47,5 @@ chargers:
|
|||
soc:
|
||||
source: js
|
||||
script: |
|
||||
55
|
||||
40
|
||||
features: [integrateddevice, heating, switchdevice]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue