,
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;
diff --git a/assets/js/components/Vehicles/Vehicle.vue b/assets/js/components/Vehicles/Vehicle.vue
index 0a04dc1a8..660fc8548 100644
--- a/assets/js/components/Vehicles/Vehicle.vue
+++ b/assets/js/components/Vehicles/Vehicle.vue
@@ -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"
/>
,
capacity: Number,
range: Number,
rangePerSoc: Number,
diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts
index e04fe5b53..bb8305bf5 100644
--- a/assets/js/types/evcc.ts
+++ b/assets/js/types/evcc.ts
@@ -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 {
diff --git a/core/keys/loadpoint.go b/core/keys/loadpoint.go
index 51aa53d39..3384ed4fc 100644
--- a/core/keys/loadpoint.go
+++ b/core/keys/loadpoint.go
@@ -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"
diff --git a/core/loadpoint.go b/core/loadpoint.go
index bcac78276..92bd04778 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -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)
diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go
index adc632470..71051f4db 100644
--- a/core/loadpoint/api.go
+++ b/core/loadpoint/api.go
@@ -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
diff --git a/core/loadpoint/config.go b/core/loadpoint/config.go
index f547b2e8a..9268d5cdd 100644
--- a/core/loadpoint/config.go
+++ b/core/loadpoint/config.go
@@ -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 {
diff --git a/core/loadpoint/config_test.go b/core/loadpoint/config_test.go
new file mode 100644
index 000000000..72c5b8166
--- /dev/null
+++ b/core/loadpoint/config_test.go
@@ -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")
+}
diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go
index b02961aae..3f43e60a4 100644
--- a/core/loadpoint/mock.go
+++ b/core/loadpoint/mock.go
@@ -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()
diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go
index e8403bc2e..04a88a26b 100644
--- a/core/loadpoint_api.go
+++ b/core/loadpoint_api.go
@@ -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()
diff --git a/i18n/de.json b/i18n/de.json
index fff78d71b..9a54df23b 100644
--- a/i18n/de.json
+++ b/i18n/de.json
@@ -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.",
diff --git a/i18n/en.json b/i18n/en.json
index e5dacae08..806c9deeb 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -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}.",
diff --git a/server/http_config_loadpoint_handler.go b/server/http_config_loadpoint_handler.go
index 666d75785..87f71fcb9 100644
--- a/server/http_config_loadpoint_handler.go
+++ b/server/http_config_loadpoint_handler.go
@@ -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(),