From 5f9047531dd06aae820fd9ca670f17f24dfb30b0 Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Fri, 19 Jun 2026 17:50:22 +0200 Subject: [PATCH] Heating: add temperature range, add heating colors (#30995) --- assets/css/app.css | 11 + .../js/components/Config/LoadpointModal.vue | 47 ++++ .../Loadpoints/Loadpoint.stories.ts | 23 ++ assets/js/components/Loadpoints/Loadpoint.vue | 4 + .../js/components/Vehicles/LimitSocSelect.vue | 9 +- assets/js/components/Vehicles/Soc.stories.ts | 247 ++++++++++++++++++ assets/js/components/Vehicles/Soc.vue | 235 ++++++++++++++--- assets/js/components/Vehicles/Vehicle.vue | 4 + assets/js/types/evcc.ts | 7 + core/keys/loadpoint.go | 1 + core/loadpoint.go | 8 + core/loadpoint/api.go | 5 + core/loadpoint/config.go | 8 + core/loadpoint/config_test.go | 25 ++ core/loadpoint/mock.go | 26 ++ core/loadpoint_api.go | 23 ++ i18n/de.json | 5 + i18n/en.json | 5 + server/http_config_loadpoint_handler.go | 1 + 19 files changed, 663 insertions(+), 31 deletions(-) create mode 100644 assets/js/components/Vehicles/Soc.stories.ts create mode 100644 core/loadpoint/config_test.go diff --git a/assets/css/app.css b/assets/css/app.css index 22caccc07..51e5e0fab 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -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; diff --git a/assets/js/components/Config/LoadpointModal.vue b/assets/js/components/Config/LoadpointModal.vue index 1466a4d61..0d4e3cfd2 100644 --- a/assets/js/components/Config/LoadpointModal.vue +++ b/assets/js/components/Config/LoadpointModal.vue @@ -540,6 +540,49 @@

{{ $t("config.loadpoint.noVehicles") }}

+ +
+
{{ $t("config.loadpoint.temperatureRangeTitle") }}
+

+ {{ $t("config.loadpoint.temperatureRangeHelp") }} +

+
+ + + + + + + +
+
@@ -619,6 +662,10 @@ const defaultValues = { poll: { mode: "charging", interval: 60 * nsPerMin }, estimate: true, }, + ui: { + minTemp: 0, + maxTemp: 100, + }, vehicle: "", charger: "", circuit: "", diff --git a/assets/js/components/Loadpoints/Loadpoint.stories.ts b/assets/js/components/Loadpoints/Loadpoint.stories.ts index c8d23adef..92a62c124 100644 --- a/assets/js/components/Loadpoints/Loadpoint.stories.ts +++ b/assets/js/components/Loadpoints/Loadpoint.stories.ts @@ -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; // 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, +}; diff --git a/assets/js/components/Loadpoints/Loadpoint.vue b/assets/js/components/Loadpoints/Loadpoint.vue index 1e218b0d2..a5a24b0e6 100644 --- a/assets/js/components/Loadpoints/Loadpoint.vue +++ b/assets/js/components/Loadpoints/Loadpoint.vue @@ -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, + // heating display range (ui-only) + ui: Object as PropType, + // vehicle connected: Boolean, // charging: Boolean, diff --git a/assets/js/components/Vehicles/LimitSocSelect.vue b/assets/js/components/Vehicles/LimitSocSelect.vue index 42eaf038f..765d52f21 100644 --- a/assets/js/components/Vehicles/LimitSocSelect.vue +++ b/assets/js/components/Vehicles/LimitSocSelect.vue @@ -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 }); } diff --git a/assets/js/components/Vehicles/Soc.stories.ts b/assets/js/components/Vehicles/Soc.stories.ts new file mode 100644 index 000000000..093b91597 --- /dev/null +++ b/assets/js/components/Vehicles/Soc.stories.ts @@ -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; + +// 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 = (args) => ({ + components: { Soc }, + setup() { + return { args, cardStyle }; + }, + template: '
', +}); + +// --- 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 = () => ({ + components: { Soc }, + setup() { + return { columns: [chargingVariants, heatingVariants] }; + }, + template: ` +
+
+
+
{{ v.name }}
+ +
+
+
+ `, +}); +Overview.parameters = { controls: { disable: true } }; diff --git a/assets/js/components/Vehicles/Soc.vue b/assets/js/components/Vehicles/Soc.vue index 617828a95..bb2b8830b 100644 --- a/assets/js/components/Vehicles/Soc.vue +++ b/assets/js/components/Vehicles/Soc.vue @@ -1,24 +1,30 @@