Heating: add temperature range, add heating colors (#30995)

This commit is contained in:
Michael Geers 2026-06-19 17:50:22 +02:00 • committed by GitHub
parent 8b52365011
commit 5f9047531d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 663 additions and 31 deletions

View file

@ -54,6 +54,17 @@
--evcc-accent2: var(--evcc-darker-green);
--evcc-accent3: var(--evcc-darkest-green);
/* heating temperature bar: thermometer scale (cold -> hot) */
--evcc-temp-cold: #1b6fba;
--evcc-temp-warm: #ffa700;
--evcc-temp-hot: #e30022;
--evcc-heating-gradient: linear-gradient(
90deg,
var(--evcc-temp-cold) 5%,
var(--evcc-temp-warm) 20%,
var(--evcc-temp-warm) 40%,
var(--evcc-temp-hot) 95%
);
--evcc-transition-slow: 1000ms;
--evcc-transition-medium: 500ms;
--evcc-transition-fast: 250ms;

View file

@ -540,6 +540,49 @@
<p class="text-muted">{{ $t("config.loadpoint.noVehicles") }}</p>
</div>
</div>
<div v-if="chargerIsHeating">
<h6>{{ $t("config.loadpoint.temperatureRangeTitle") }}</h6>
<p class="text-muted">
{{ $t("config.loadpoint.temperatureRangeHelp") }}
</p>
<div class="row">
<FormRow
id="loadpointMinTemp"
:label="$t('config.loadpoint.minTempLabel')"
class="col-sm-6 mb-sm-0"
>
<PropertyField
id="loadpointMinTemp"
v-model="values.ui.minTemp"
type="Float"
unit="°C"
size="w-25 w-min-200"
class="me-2"
/>
</FormRow>
<FormRow
id="loadpointMaxTemp"
:label="$t('config.loadpoint.maxTempLabel')"
class="col-sm-6 mb-sm-0"
:help="
values.ui.maxTemp <= values.ui.minTemp
? $t('config.loadpoint.maxTempHelp')
: undefined
"
>
<PropertyField
id="loadpointMaxTemp"
v-model="values.ui.maxTemp"
type="Float"
unit="°C"
size="w-25 w-min-200"
class="me-2"
/>
</FormRow>
</div>
</div>
</div>
</div>
</div>
@ -619,6 +662,10 @@ const defaultValues = {
poll: { mode: "charging", interval: 60 * nsPerMin },
estimate: true,
},
ui: {
minTemp: 0,
maxTemp: 100,
},
vehicle: "",
charger: "",
circuit: "",

View file

@ -14,6 +14,12 @@ export default {
parameters: {
layout: "centered",
},
argTypes: {
vehicleSoc: { control: { type: "range", min: 0, max: 100, step: 1 } },
limitSoc: { control: { type: "range", min: 0, max: 100, step: 1 } },
effectiveLimitSoc: { control: { type: "range", min: 0, max: 100, step: 1 } },
vehicleLimitSoc: { control: { type: "range", min: 0, max: 100, step: 1 } },
},
} as Meta<typeof Loadpoint>;
// Based on actual API state structure from demo.evcc.io
@ -170,3 +176,20 @@ ChargerIconNoVehicle.args = {
vehicleName: "",
vehicles: [],
};
// integrated heater: temperature bar (30..70 range), device limit marker
export const Heating = Template.bind({});
Heating.args = {
...baseState,
title: "Warmwasser",
chargerIcon: "heater",
mode: CHARGE_MODE.NOW,
chargerFeatureHeating: true,
chargerFeatureIntegratedDevice: true,
vehicleName: "",
vehicles: [],
ui: { minTemp: 30, maxTemp: 70 },
vehicleSoc: 48,
effectiveLimitSoc: 60,
vehicleLimitSoc: 65,
};

View file

@ -120,6 +120,7 @@ import type {
Forecast,
SMART_COST_TYPE,
BATTERY_MODE,
LoadpointUi,
} from "@/types/evcc";
import type { PlanStrategy } from "@/components/ChargingPlans/types";
@ -168,6 +169,9 @@ export default defineComponent({
chargerFeatureContinuous: Boolean,
chargerIcon: String as PropType<string | null>,
// heating display range (ui-only)
ui: Object as PropType<LoadpointUi>,
// vehicle
connected: Boolean,
// charging: Boolean,

View file

@ -34,12 +34,19 @@ export default defineComponent({
limitSoc: { type: Number, default: 0 },
rangePerSoc: Number,
heating: Boolean,
minTemp: { type: Number, default: 0 },
maxTemp: { type: Number, default: 0 },
},
emits: ["limit-soc-updated"],
computed: {
rangeActive() {
return this.heating && this.maxTemp > this.minTemp;
},
options() {
const result = [];
for (let soc = 20; soc <= 100; soc += this.step) {
const start = this.rangeActive ? this.minTemp : 20;
const end = this.rangeActive ? this.maxTemp : 100;
for (let soc = start; soc <= end; soc += this.step) {
const text = this.fmtSocOption(soc, this.rangePerSoc, distanceUnit(), this.heating);
result.push({ soc, text });
}

View file

@ -0,0 +1,247 @@
import Soc from "./Soc.vue";
import type { Meta, StoryFn } from "@storybook/vue3";
// non-heating SoC bar (percentage scale). current soc steps up across the
// variants; the heating variants use the matching position on the 20..60 scale.
const baseState = {
connected: true,
socBasedCharging: true,
socBasedPlanning: true,
vehicleSoc: 40,
effectiveLimitSoc: 90, // manual limit
vehicleLimitSoc: 0,
enabled: false,
charging: false,
heating: false,
};
// heating bar: vehicleSoc is the current temperature, ui defines the scale
// (20..60), effectiveLimitSoc / vehicleLimitSoc are target temperatures.
// temp = 20 + soc * 0.4, so positions line up with the charging column.
const heatingBase = {
...baseState,
heating: true,
ui: { minTemp: 20, maxTemp: 60 },
vehicleSoc: 36, // ~40%
effectiveLimitSoc: 56, // manual limit, ~90%
};
export default {
title: "Vehicles/Soc",
component: Soc,
argTypes: {
connected: { control: "boolean" },
enabled: { control: "boolean" },
charging: { control: "boolean" },
heating: { control: "boolean" },
socBasedCharging: { control: "boolean" },
socBasedPlanning: { control: "boolean" },
vehicleSoc: { control: "number" },
vehicleLimitSoc: { control: "number" },
effectiveLimitSoc: { control: "number" },
effectivePlanSoc: { control: "number" },
minSoc: { control: "number" },
minSocNotReached: { control: "boolean" },
ui: { control: "object" },
},
} as Meta<typeof Soc>;
// give the bar a fixed width and the card background (var(--evcc-box)) it lives on
// in the app, so the grey track (var(--evcc-background)) reads correctly
const cardStyle =
"width: 340px; padding: 1rem; background: var(--evcc-box); border-radius: 0.5rem;";
const Template: StoryFn<typeof Soc> = (args) => ({
components: { Soc },
setup() {
return { args, cardStyle };
},
template: '<div :style="cardStyle"><Soc v-bind="args" /></div>',
});
// --- charging (percentage) variants, current soc increasing ---
export const MinSocNotReached = Template.bind({});
MinSocNotReached.args = {
...baseState,
enabled: true,
charging: true,
vehicleSoc: 10,
minSoc: 20,
minSocNotReached: true,
};
// very low / low current soc, actively charging
export const ChargingVeryLow = Template.bind({});
ChargingVeryLow.args = { ...baseState, enabled: true, charging: true, vehicleSoc: 5 };
export const ChargingLow = Template.bind({});
ChargingLow.args = { ...baseState, enabled: true, charging: true, vehicleSoc: 20 };
export const Connected = Template.bind({});
Connected.args = { ...baseState, vehicleSoc: 30 };
export const Ready = Template.bind({});
Ready.args = { ...baseState, enabled: true, vehicleSoc: 45 };
export const Charging = Template.bind({});
Charging.args = { ...baseState, enabled: true, charging: true, vehicleSoc: 60 };
// manual limit (slider) below the vehicle's own limit -> vehicle marker sits right of the thumb
export const VehicleLimitManualBelow = Template.bind({});
VehicleLimitManualBelow.args = {
...baseState,
enabled: true,
charging: true,
vehicleSoc: 65,
vehicleLimitSoc: 80,
effectiveLimitSoc: 70,
};
// manual limit above the vehicle's own limit -> fill clamps at the vehicle marker, thumb beyond
export const VehicleLimitManualAbove = Template.bind({});
VehicleLimitManualAbove.args = {
...baseState,
enabled: true,
charging: true,
vehicleSoc: 70,
vehicleLimitSoc: 80,
effectiveLimitSoc: 90,
};
// current soc nearly at the top of the scale, limit raised to make room
export const NearlyFull = Template.bind({});
NearlyFull.args = {
...baseState,
enabled: true,
charging: true,
vehicleSoc: 96,
effectiveLimitSoc: 100,
};
export const Disconnected = Template.bind({});
Disconnected.args = { ...baseState, connected: false };
// no vehicle SoC: energy-based bar (chargedEnergy vs limitEnergy), no slider
export const NoSoc = Template.bind({});
NoSoc.args = {
...baseState,
socBasedCharging: false,
socBasedPlanning: false,
enabled: true,
charging: true,
chargedEnergy: 14000,
};
// --- heating (temperature range) variants, current temp increasing ---
export const MinTempNotReached = Template.bind({});
MinTempNotReached.args = {
...heatingBase,
enabled: true,
charging: true,
vehicleSoc: 24,
minSoc: 28,
minSocNotReached: true,
};
// very low / low current temp, actively heating (temp = 20 + soc% * 0.4)
export const HeatingChargingVeryLow = Template.bind({});
HeatingChargingVeryLow.args = { ...heatingBase, enabled: true, charging: true, vehicleSoc: 22 };
export const HeatingChargingLow = Template.bind({});
HeatingChargingLow.args = { ...heatingBase, enabled: true, charging: true, vehicleSoc: 28 };
export const HeatingIdle = Template.bind({});
HeatingIdle.args = { ...heatingBase, vehicleSoc: 32 };
export const HeatingReady = Template.bind({});
HeatingReady.args = { ...heatingBase, enabled: true, vehicleSoc: 38 };
export const HeatingCharging = Template.bind({});
HeatingCharging.args = { ...heatingBase, enabled: true, charging: true, vehicleSoc: 44 };
export const HeaterLimitManualBelow = Template.bind({});
HeaterLimitManualBelow.args = {
...heatingBase,
enabled: true,
charging: true,
vehicleSoc: 46,
vehicleLimitSoc: 52,
effectiveLimitSoc: 48,
};
export const HeaterLimitManualAbove = Template.bind({});
HeaterLimitManualAbove.args = {
...heatingBase,
enabled: true,
charging: true,
vehicleSoc: 48,
vehicleLimitSoc: 52,
effectiveLimitSoc: 56,
};
// current temp nearly at the top of the scale, limit raised to make room
export const HeatingNearlyFull = Template.bind({});
HeatingNearlyFull.args = {
...heatingBase,
enabled: true,
charging: true,
vehicleSoc: 58,
effectiveLimitSoc: 60,
};
export const HeatingDisconnected = Template.bind({});
HeatingDisconnected.args = { ...heatingBase, connected: false };
// no temperature reading yet: warm bar, no slider
export const HeatingNoReading = Template.bind({});
HeatingNoReading.args = { ...heatingBase, vehicleSoc: 0, enabled: true, charging: true };
// --- overview: charging (left) vs heating (right), each row the same situation ---
const chargingVariants = [
{ name: "Disconnected", args: Disconnected.args },
{ name: "ChargingVeryLow", args: ChargingVeryLow.args },
{ name: "MinSocNotReached", args: MinSocNotReached.args },
{ name: "ChargingLow", args: ChargingLow.args },
{ name: "Connected", args: Connected.args },
{ name: "Ready", args: Ready.args },
{ name: "Charging", args: Charging.args },
{ name: "VehicleLimitManualBelow", args: VehicleLimitManualBelow.args },
{ name: "VehicleLimitManualAbove", args: VehicleLimitManualAbove.args },
{ name: "NearlyFull", args: NearlyFull.args },
{ name: "NoSoc", args: NoSoc.args },
];
const heatingVariants = [
{ name: "HeatingDisconnected", args: HeatingDisconnected.args },
{ name: "HeatingChargingVeryLow", args: HeatingChargingVeryLow.args },
{ name: "MinTempNotReached", args: MinTempNotReached.args },
{ name: "HeatingChargingLow", args: HeatingChargingLow.args },
{ name: "HeatingIdle", args: HeatingIdle.args },
{ name: "HeatingReady", args: HeatingReady.args },
{ name: "HeatingCharging", args: HeatingCharging.args },
{ name: "HeaterLimitManualBelow", args: HeaterLimitManualBelow.args },
{ name: "HeaterLimitManualAbove", args: HeaterLimitManualAbove.args },
{ name: "HeatingNearlyFull", args: HeatingNearlyFull.args },
{ name: "HeatingNoReading", args: HeatingNoReading.args },
];
export const Overview: StoryFn<typeof Soc> = () => ({
components: { Soc },
setup() {
return { columns: [chargingVariants, heatingVariants] };
},
template: `
<div style="display: grid; grid-template-columns: repeat(2, minmax(320px, 1fr)); gap: 1.5rem; align-items: start;">
<div v-for="(column, i) in columns" :key="i" style="display: flex; flex-direction: column; gap: 1.5rem;">
<div v-for="v in column" :key="v.name" style="padding: 1rem; background: var(--evcc-box); border-radius: 0.5rem;">
<div style="margin-bottom: 0.75rem; color: var(--evcc-default-text); font-size: 0.85rem; opacity: 0.7;">{{ v.name }}</div>
<Soc v-bind="v.args" />
</div>
</div>
</div>
`,
});
Overview.parameters = { controls: { disable: true } };

View file

@ -1,24 +1,30 @@
<template>
<div class="vehicle-soc">
<div class="progress">
<div
v-if="connected"
class="progress-bar"
role="progressbar"
:class="{
[progressColor]: true,
'progress-bar-striped': charging,
'progress-bar-animated': charging,
}"
:style="{ width: `${vehicleSocDisplayWidth}%`, ...transition }"
></div>
<div
v-if="remainingSocWidth !== null && remainingSocWidth > 0 && enabled && connected"
class="progress-bar bg-muted"
role="progressbar"
:class="progressColor"
:style="{ width: `${remainingSocWidth}%`, ...transition }"
></div>
<div v-if="connected" class="bar-fills">
<!-- behind: track from current position up to the limit, muted -->
<div
v-if="showRemaining"
class="progress-bar bar-fill bar-fill--remaining"
role="progressbar"
:class="remainingClass"
:style="remainingStyle"
></div>
<!-- front: current fill (solid color, or heating gradient) -->
<div
v-if="!showWarmBar"
class="progress-bar bar-fill"
role="progressbar"
:class="fillClass"
:style="mainFillStyle"
>
<div v-if="heatingActive && charging" class="heating-stripes"></div>
</div>
<!-- heating without a reading yet: warm half of the scale -->
<div v-else class="progress-bar bar-fill thermal-warm" role="progressbar">
<div v-if="charging" class="heating-stripes"></div>
</div>
</div>
<div
v-show="vehicleLimitSoc"
ref="vehicleLimitSoc"
@ -26,7 +32,7 @@
data-bs-toggle="tooltip"
title=" "
:class="{ 'vehicle-limit-soc--active': vehicleLimitSocActive }"
:style="{ left: `${vehicleLimitSoc}%` }"
:style="{ left: `${vehicleLimitSocPosition}%` }"
/>
<div
v-show="energyLimitMarkerPosition"
@ -52,14 +58,15 @@
</div>
<div class="target">
<input
v-if="socBasedCharging && connected"
v-if="socBasedCharging && connected && (!heating || heatingHasTemp)"
type="range"
min="0"
max="100"
:min="lowerBound"
:max="upperBound"
:step="step"
:value="visibleLimitSoc"
class="slider"
:class="{ 'slider--active': sliderActive }"
:class="{ 'slider--active': sliderActive, 'slider--heating': heating }"
:style="{ '--thumb-pos': `${limitPosition}%` }"
@mousedown="changeLimitSocStart"
@touchstart="changeLimitSocStart"
@input="movedLimitSoc"
@ -74,7 +81,8 @@
import Tooltip from "bootstrap/js/dist/tooltip";
import "@h2d2/shopicons/es/regular/clock";
import formatter from "@/mixins/formatter";
import { defineComponent } from "vue";
import { defineComponent, type PropType } from "vue";
import type { LoadpointUi } from "@/types/evcc";
export default defineComponent({
name: "VehicleSoc",
@ -86,6 +94,7 @@ export default defineComponent({
enabled: Boolean,
charging: Boolean,
heating: Boolean,
ui: Object as PropType<LoadpointUi>,
minSoc: { type: Number, default: 0 },
minSocNotReached: Boolean,
effectivePlanSoc: { type: Number, default: 0 },
@ -109,10 +118,85 @@ export default defineComponent({
step() {
return this.heating ? 1 : 5;
},
minTemp() {
return this.ui?.minTemp ?? 0;
},
maxTemp() {
return this.ui?.maxTemp ?? 100;
},
lowerBound() {
if (!this.heating) {
return 0;
}
// fixed scale; an out-of-range temp clips to the edge (see toPercent)
return this.maxTemp > this.minTemp ? this.minTemp : 0;
},
upperBound() {
if (!this.heating) {
return 100;
}
return this.maxTemp > this.minTemp ? this.maxTemp : 100;
},
limitWidth() {
// current position plus the remaining span up to the limit (absolute, from 0)
return this.vehicleSocDisplayWidth + (this.remainingSocWidth ?? 0);
},
limitPosition() {
return this.toPercent(this.visibleLimitSoc);
},
heatingHasTemp() {
return this.socBasedCharging && this.vehicleSoc > 0;
},
// heating with a usable reading -> render the temperature gradient.
// emergency (minSocNotReached) falls back to the solid danger color.
heatingActive() {
return this.heating && !this.minSocNotReached;
},
showWarmBar() {
return this.heatingActive && !this.heatingHasTemp;
},
showRemaining() {
return (
this.connected &&
this.enabled &&
this.remainingSocWidth !== null &&
this.remainingSocWidth > 0 &&
(!this.heating || this.heatingHasTemp)
);
},
fillClass() {
if (this.heatingActive) {
return ["thermal"];
}
return [
this.progressColor,
{ "progress-bar-striped": this.charging, "progress-bar-animated": this.charging },
];
},
remainingClass() {
return this.heatingActive ? ["thermal"] : [this.progressColor];
},
mainFillStyle() {
return {
width: `${this.vehicleSocDisplayWidth}%`,
"--temp-pos": this.vehicleSocDisplayWidth,
...this.transition,
};
},
remainingStyle() {
return {
width: `${this.limitWidth}%`,
"--temp-pos": this.vehicleSocDisplayWidth,
...this.transition,
};
},
vehicleLimitSocPosition() {
return this.toPercent(this.vehicleLimitSoc);
},
vehicleSocDisplayWidth() {
if (this.socBasedCharging) {
if (this.vehicleSoc >= 0) {
return this.vehicleSoc;
return this.toPercent(this.vehicleSoc);
}
return 100;
} else {
@ -136,7 +220,7 @@ export default defineComponent({
},
planMarkerPosition(): number {
if (this.socBasedPlanning) {
return this.effectivePlanSoc;
return this.toPercent(this.effectivePlanSoc);
}
const maxEnergy = Math.max(this.planEnergy, this.limitEnergy);
if (maxEnergy) {
@ -186,14 +270,14 @@ export default defineComponent({
return null;
}
if (this.minSocNotReached) {
return this.minSoc - this.vehicleSoc;
return this.toPercent(this.minSoc) - this.vehicleSocDisplayWidth;
}
const limit = Math.min(
this.vehicleLimitSoc || 100,
Math.max(this.visibleLimitSoc, this.effectivePlanSoc || 0)
);
if (limit > this.vehicleSoc) {
return limit - this.vehicleSoc;
return this.toPercent(limit) - this.vehicleSocDisplayWidth;
}
} else {
return 100 - this.vehicleSocDisplayWidth;
@ -217,6 +301,14 @@ export default defineComponent({
this.updateTooltip();
},
methods: {
toPercent(value: number) {
const span = this.upperBound - this.lowerBound;
if (span <= 0) {
return 0;
}
const pct = ((value - this.lowerBound) / span) * 100;
return Math.min(100, Math.max(0, pct));
},
changeLimitSocStart(e: Event) {
this.dragging = true;
e.stopPropagation();
@ -232,7 +324,7 @@ export default defineComponent({
movedLimitSoc(e: Event) {
const value = parseInt((e.target as HTMLInputElement).value, 10);
e.stopPropagation();
const minLimit = 20;
const minLimit = this.heating ? this.lowerBound : 20;
if (value < minLimit) {
(e.target as HTMLInputElement).value = minLimit.toString();
this.selectedLimitSoc = value;
@ -266,15 +358,79 @@ export default defineComponent({
--label-height: 26px;
position: relative;
height: var(--height);
/* 100cqw on the thumb gradient resolves to the bar width */
container-type: inline-size;
}
.progress {
position: relative;
height: 100%;
font-size: 1rem;
background: var(--evcc-background);
}
.progress-bar.bg-muted {
/* clip wrapper so the fills get the bar's rounded corners; markers stay outside */
.bar-fills {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: inherit;
}
/* both fills stack from the left edge; the remaining one sits behind the front fill */
.bar-fill {
position: absolute;
left: 0;
top: 0;
bottom: 0;
overflow: hidden;
}
.bar-fill--remaining {
opacity: 0.5;
}
/* enlarge a sub-window of the gradient and slide it by temp: cold (left) at low
temp -> solid blue, warm (right) at high temp. 500cqw = 5x zoom on the track,
so each state shows a narrow slice and reads as a distinct color. */
.thermal {
background: var(--evcc-heating-gradient);
background-size: 500cqw 100%;
background-position-x: calc(var(--temp-pos, 0) * -4cqw);
background-repeat: no-repeat;
}
/* no reading: full-width bar showing only the warm (right) half of the scale */
.thermal-warm {
right: 0;
background: var(--evcc-heating-gradient);
background-size: 200% 100%;
background-position: right;
}
.heating-stripes {
position: absolute;
inset: 0;
/* same diagonal direction as the charging (bootstrap) striped bar */
background-image: linear-gradient(
45deg,
rgba(255, 255, 255, 0.24) 25%,
transparent 25%,
transparent 50%,
rgba(255, 255, 255, 0.24) 50%,
rgba(255, 255, 255, 0.24) 75%,
transparent 75%,
transparent
);
background-size: 18px 18px;
animation: stripeShift 0.7s linear infinite;
}
@keyframes stripeShift {
from {
background-position: 0 0;
}
to {
background-position: 18px 0;
}
}
@media (prefers-reduced-motion: reduce) {
.heating-stripes {
animation: none;
}
}
.bg-light {
color: var(--bs-gray-dark);
}
@ -342,6 +498,25 @@ export default defineComponent({
.slider--active::-moz-range-thumb {
background-color: var(--evcc-dark-green);
}
/* thumb shows the gradient slice at its position, shifted via --thumb-pos */
.slider--heating.slider--active::-webkit-slider-thumb {
background: var(--evcc-heating-gradient);
background-size: 100cqw 100%;
background-position-x: var(--thumb-pos, 50%);
background-repeat: no-repeat;
}
.slider--heating.slider--active::-moz-range-thumb {
background: var(--evcc-heating-gradient);
background-size: 100cqw 100%;
background-position-x: var(--thumb-pos, 50%);
background-repeat: no-repeat;
}
.slider--heating:not(.slider--active)::-webkit-slider-thumb {
background: var(--evcc-gray);
}
.slider--heating:not(.slider--active)::-moz-range-thumb {
background: var(--evcc-gray);
}
.vehicle-limit-soc {
position: absolute;
top: 0;

View file

@ -62,6 +62,8 @@
:limit-soc="displayLimitSoc"
:range-per-soc="rangePerSoc"
:heating="heating"
:min-temp="ui?.minTemp ?? 0"
:max-temp="ui?.maxTemp ?? 100"
@limit-soc-updated="limitSocUpdated"
/>
<LimitEnergySelect
@ -95,6 +97,7 @@ import {
type Forecast,
type VehicleStatus,
type Vehicle,
type LoadpointUi,
} from "@/types/evcc";
import type { PlanStrategy } from "@/components/ChargingPlans/types";
import BatteryBoostButton from "../Loadpoints/BatteryBoostButton.vue";
@ -172,6 +175,7 @@ export default defineComponent({
vehicleLimitSoc: Number,
vehicleNotReachable: Boolean,
minSocNotReached: Boolean,
ui: Object as PropType<LoadpointUi>,
capacity: Number,
range: Number,
rangePerSoc: Number,

View file

@ -270,6 +270,12 @@ export interface ConfigLoadpoint {
};
estimate: boolean;
};
ui: LoadpointUi;
}
export interface LoadpointUi {
minTemp: number;
maxTemp: number;
}
export enum SMART_COST_TYPE {
@ -361,6 +367,7 @@ export interface Loadpoint {
vehicleTitle: string;
vehicleWelcomeActive: boolean;
batteryBoostLimit: number;
ui?: LoadpointUi;
}
export interface UiLoadpoint extends Loadpoint {

View file

@ -19,6 +19,7 @@ const (
LimitEnergy = "limitEnergy" // limit energy
Soc = "soc"
Thresholds = "thresholds"
UI = "ui" // display-only ui settings (json)
EnableThreshold = "enableThreshold"
DisableThreshold = "disableThreshold"
EnableDelay = "enableDelay"

View file

@ -98,6 +98,7 @@ type Loadpoint struct {
Soc loadpoint.SocConfig
Enable, Disable loadpoint.ThresholdConfig
ui loadpoint.UIConfig // display-only, not used in control logic
// from yaml
DefaultMode api.ChargeMode `mapstructure:"mode"` // Default charge mode, used for disconnect
@ -383,6 +384,11 @@ func (lp *Loadpoint) restoreSettings() {
lp.setSocConfig(socConfig)
}
var ui loadpoint.UIConfig
if err := lp.settings.Json(keys.UI, &ui); err == nil {
lp.ui = ui
}
t, err1 := lp.settings.Time(keys.PlanTime)
v, err2 := lp.settings.Float(keys.PlanEnergy)
if err1 == nil && err2 == nil {
@ -696,6 +702,8 @@ func (lp *Loadpoint) Prepare(site site.API, uiChan chan<- util.Param, pushChan c
lp.publish(keys.EnableDelay, lp.Enable.Delay)
lp.publish(keys.DisableDelay, lp.Disable.Delay)
lp.publish(keys.UI, lp.ui)
if phases := lp.getChargerPhysicalPhases(); phases != 0 {
if lp.phasesConfigured != phases && lp.phasesConfigured != 0 {
lp.log.WARN.Printf("configured phases %d do not match physical phases %d", lp.phasesConfigured, phases)

View file

@ -141,6 +141,11 @@ type API interface {
// SetSocConfig sets the soc poll settings
SetSocConfig(soc SocConfig)
// GetUI returns the display-only ui settings
GetUI() UIConfig
// SetUI sets the display-only ui settings
SetUI(ui UIConfig)
// GetThresholds returns the PV mode threshold settings
GetThresholds() ThresholdsConfig
// SetThresholds sets the PV mode threshold settings

View file

@ -36,6 +36,13 @@ type DynamicConfig struct {
Thresholds ThresholdsConfig `json:"thresholds"`
Soc SocConfig `json:"soc"`
UI UIConfig `json:"ui"`
}
// UIConfig holds display-only settings. Not used in control logic.
type UIConfig struct {
MinTemp float64 `json:"minTemp"`
MaxTemp float64 `json:"maxTemp"`
}
func SplitConfig(payload map[string]any) (DynamicConfig, map[string]any, error) {
@ -71,6 +78,7 @@ func (payload DynamicConfig) Apply(lp API) error {
// TODO mode warning
lp.SetSocConfig(payload.Soc)
lp.SetUI(payload.UI)
mode, err := api.ChargeModeString(payload.DefaultMode)
if err == nil {

View file

@ -0,0 +1,25 @@
package loadpoint
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSplitConfigUI(t *testing.T) {
payload := map[string]any{
"title": "Water Heater",
"ui": map[string]any{
"minTemp": 20.0,
"maxTemp": 45.0,
},
}
dynamic, other, err := SplitConfig(payload)
require.NoError(t, err)
assert.Equal(t, 20.0, dynamic.UI.MinTemp)
assert.Equal(t, 45.0, dynamic.UI.MaxTemp)
assert.NotContains(t, other, "ui")
}

View file

@ -685,6 +685,20 @@ func (mr *MockAPIMockRecorder) GetTitle() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTitle", reflect.TypeOf((*MockAPI)(nil).GetTitle))
}
// GetUI mocks base method.
func (m *MockAPI) GetUI() UIConfig {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetUI")
ret0, _ := ret[0].(UIConfig)
return ret0
}
// GetUI indicates an expected call of GetUI.
func (mr *MockAPIMockRecorder) GetUI() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUI", reflect.TypeOf((*MockAPI)(nil).GetUI))
}
// GetVehicle mocks base method.
func (m *MockAPI) GetVehicle() api.Vehicle {
m.ctrl.T.Helper()
@ -1051,6 +1065,18 @@ func (mr *MockAPIMockRecorder) SetTitle(arg0 any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetTitle", reflect.TypeOf((*MockAPI)(nil).SetTitle), arg0)
}
// SetUI mocks base method.
func (m *MockAPI) SetUI(ui UIConfig) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "SetUI", ui)
}
// SetUI indicates an expected call of SetUI.
func (mr *MockAPIMockRecorder) SetUI(ui any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetUI", reflect.TypeOf((*MockAPI)(nil).SetUI), ui)
}
// SetVehicle mocks base method.
func (m *MockAPI) SetVehicle(vehicle api.Vehicle) {
m.ctrl.T.Helper()

View file

@ -466,6 +466,29 @@ func (lp *Loadpoint) SetSocConfig(soc loadpoint.SocConfig) {
lp.setSocConfig(soc)
}
// GetUI returns the display-only ui settings
func (lp *Loadpoint) GetUI() loadpoint.UIConfig {
lp.RLock()
defer lp.RUnlock()
return lp.ui
}
func (lp *Loadpoint) setUI(ui loadpoint.UIConfig) {
lp.ui = ui
lp.publish(keys.UI, ui)
lp.settings.SetJson(keys.UI, ui)
}
// SetUI sets the display-only ui settings. Not used in control logic.
func (lp *Loadpoint) SetUI(ui loadpoint.UIConfig) {
lp.Lock()
defer lp.Unlock()
lp.log.DEBUG.Printf("set ui config: %+v", ui)
lp.setUI(ui)
}
// GetThresholds returns the PV mode threshold settings
func (lp *Loadpoint) GetThresholds() loadpoint.ThresholdsConfig {
lp.RLock()

View file

@ -378,8 +378,11 @@
"estimateLabel": "Ladestand zwischen API-Updates interpolieren",
"maxCurrentHelp": "Muss größer als der Mindeststrom sein.",
"maxCurrentLabel": "Maximaler Strom",
"maxTempHelp": "Muss größer als die Mindesttemperatur sein.",
"maxTempLabel": "Maximaltemperatur",
"minCurrentHelp": "Gehe nur unter 6 A, wenn du weißt, was du tust.",
"minCurrentLabel": "Minimaler Strom",
"minTempLabel": "Mindesttemperatur",
"noVehicles": "Keine Fahrzeuge konfiguriert.",
"option": {
"charging": "Ladepunkt hinzufügen",
@ -409,6 +412,8 @@
"solarBehaviorLabel": "PV-Überschuss",
"solarModeCustom": "manuell",
"solarModeMaximum": "nur Sonne",
"temperatureRangeHelp": "Anzeigebereich für den Temperaturbalken. Beeinflusst die Regelung nicht.",
"temperatureRangeTitle": "Temperaturbereich",
"thresholdDisableDelayLabel": "Ausschaltverzögerung",
"thresholdDisableHelpInvalid": "Bitte verwende einen positiven Wert.",
"thresholdDisableHelpPositive": "Laden stoppen, wenn mehr als {power} für {delay} aus dem Netz bezogen wird.",

View file

@ -377,8 +377,11 @@
"estimateLabel": "Interpolate charge level between API updates",
"maxCurrentHelp": "Must be greater than minimum current.",
"maxCurrentLabel": "Maximum current",
"maxTempHelp": "Must be greater than minimum temperature.",
"maxTempLabel": "Maximum temperature",
"minCurrentHelp": "Only go below 6 A if you know what you're doing.",
"minCurrentLabel": "Minimum current",
"minTempLabel": "Minimum temperature",
"noVehicles": "No vehicles are configured.",
"option": {
"charging": "Add charging point",
@ -408,6 +411,8 @@
"solarBehaviorLabel": "Solar",
"solarModeCustom": "custom",
"solarModeMaximum": "maximum solar",
"temperatureRangeHelp": "Display range for the temperature bar. Does not affect control.",
"temperatureRangeTitle": "Temperature range",
"thresholdDisableDelayLabel": "Disable delay",
"thresholdDisableHelpInvalid": "Please use a positive value.",
"thresholdDisableHelpPositive": "Stop, when more than {power} is used from the grid for {delay}.",

View file

@ -39,6 +39,7 @@ func getLoadpointDynamicConfig(lp loadpoint.API) loadpoint.DynamicConfig {
SmartFeedInPriorityLimit: lp.GetSmartFeedInPriorityLimit(),
Thresholds: lp.GetThresholds(),
Soc: lp.GetSocConfig(),
UI: lp.GetUI(),
PlanEnergy: planEnergy,
PlanTime: planTime,
PlanStrategy: lp.GetPlanStrategy(),