Heating: add temperature range, add heating colors (#30995)
This commit is contained in:
parent
8b52365011
commit
5f9047531d
19 changed files with 663 additions and 31 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
247
assets/js/components/Vehicles/Soc.stories.ts
Normal file
247
assets/js/components/Vehicles/Soc.stories.ts
Normal 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 } };
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
25
core/loadpoint/config_test.go
Normal file
25
core/loadpoint/config_test.go
Normal 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")
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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}.",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue