Skip to content
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
a4c591d
Charge modes: rename pv to smart, replace minpv with always charge
naltatis Aug 3, 2026
8699eba
chore: regenerate openapi artifacts
naltatis Aug 3, 2026
c6ed6f8
chore: regenerate mcp openapi docs
naltatis Aug 3, 2026
e6cc34e
UI: generalize once icon naming
naltatis Aug 3, 2026
a221778
UI: reword always charge subline to no interruptions
naltatis Aug 5, 2026
8681a6f
Merge branch 'master' into feat/smart-mode
Copilot Aug 12, 2026
6bea14d
Config: fix device-specific comment typo
Copilot Aug 12, 2026
888f9fc
UI: keep loadpoint files formatted
Copilot Aug 12, 2026
d57936b
Merge branch 'master' into feat/smart-mode
naltatis Aug 13, 2026
49442ce
Merge remote-tracking branch 'origin/master' into feat/smart-mode
naltatis Aug 20, 2026
b1ff837
Config UI: device-specific mode labels for heating loadpoints
naltatis Aug 20, 2026
c651263
Merge remote-tracking branch 'origin/master' into feat/smart-mode
naltatis Aug 25, 2026
5d3ea4e
Optimizer: restore always charge min power floor
naltatis Aug 25, 2026
957d00c
Vehicles: per-vehicle always charge default
naltatis Aug 25, 2026
ea1cd97
Loadpoint: read always charge under lock
naltatis Aug 25, 2026
54d953e
Schema: replace legacy charge modes with smart
naltatis Aug 25, 2026
dec8cb3
Docs: update charge mode references
naltatis Aug 25, 2026
00acb5b
Loadpoint: gate legacy minpv migration on current control
naltatis Aug 25, 2026
8f72c94
Loadpoint: normalize legacy default mode to smart
naltatis Aug 25, 2026
0596d0f
Dist config: smart mode wording
naltatis Aug 25, 2026
a83651a
Merge branch 'master' into feat/smart-mode
naltatis Aug 25, 2026
3d4b81f
Vehicles: simplify always charge handler
naltatis Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions api/chargemode.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ func ChargeModeString(mode string) (ChargeMode, error) {
return ModeEmpty, nil // undefined
case string(ModeNow):
return ModeNow, nil
case string(ModeSmart):
return ModeSmart, nil
case string(ModeMinPV):
return ModeMinPV, nil
Comment thread
naltatis marked this conversation as resolved.
case string(ModePV):
Expand All @@ -24,6 +26,16 @@ func ChargeModeString(mode string) (ChargeMode, error) {
}
}

// AlwaysChargeString converts string to AlwaysCharge
func AlwaysChargeString(value string) (AlwaysCharge, error) {
switch ac := AlwaysCharge(strings.ToLower(value)); ac {
case AlwaysChargeOff, AlwaysChargeOn, AlwaysChargeOnce:
return ac, nil
default:
return "", fmt.Errorf("invalid value: %s", value)
}
}

var _ encoding.TextUnmarshaler = (*ChargeMode)(nil)

func (c *ChargeMode) UnmarshalText(text []byte) error {
Expand Down
20 changes: 19 additions & 1 deletion api/chargemodestatus.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@ import (
"strings"
)

// ChargeMode is the charge operation mode. Valid values are off, now, minpv and pv
// ChargeMode is the charge operation mode. Valid values are off, smart and now
type ChargeMode string

// Charge modes
const (
ModeEmpty ChargeMode = ""
ModeOff ChargeMode = "off"
ModeNow ChargeMode = "now"
ModeSmart ChargeMode = "smart"

// deprecated, accepted on input only, normalized to smart in SetMode
ModeMinPV ChargeMode = "minpv"
ModePV ChargeMode = "pv"
)
Expand All @@ -22,6 +25,21 @@ func (c ChargeMode) String() string {
return string(c)
}

// AlwaysCharge makes smart mode charge continuously at least at minimum power
type AlwaysCharge string

// AlwaysCharge states
const (
AlwaysChargeOff AlwaysCharge = "off"
AlwaysChargeOn AlwaysCharge = "on"
AlwaysChargeOnce AlwaysCharge = "once" // resets on disconnect
)

// Active reports if always charge is currently in effect
func (a AlwaysCharge) Active() bool {
return a == AlwaysChargeOn || a == AlwaysChargeOnce
}

// ChargeStatus is the EV's charging status from A to F
type ChargeStatus string

Expand Down
2 changes: 1 addition & 1 deletion assets/js/components/ChargingPlans/Warnings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
{{ $t("main.targetCharge.targetIsAboveLimit", { limit: limitFmt }) }}
</span>
<span v-if="mode && ['off', 'now'].includes(mode)" class="d-block text-warning mb-1">
{{ $t("main.targetCharge.onlyInPvMode") }}
{{ $t("main.targetCharge.onlyInSmartMode") }}
</span>
<span v-if="timeTooFarInTheFuture" class="d-block evcc-gray mb-1">
{{ $t("main.targetCharge.targetIsTooFarInTheFuture") }}
Expand Down
2 changes: 1 addition & 1 deletion assets/js/components/Config/DeviceModal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ export function customChargerName(type: ConfigType, isHeating: boolean) {
// flattenDeviceConfig converts a GET /config/devices/:class/:id response
// into the flat shape expected by POST/PUT/test (id and name are dropped).
//
// GET (config = device-specfic):
// GET (config = device-specific):
// {
// id: 26,
// name: "db:26",
Expand Down
15 changes: 11 additions & 4 deletions assets/js/components/Config/LoadpointModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,7 @@ import PropertyField from "./PropertyField.vue";
import SelectGroup from "../Helper/SelectGroup.vue";
import api from "@/api";
import GenericModal from "../Helper/GenericModal.vue";
import chargeModeLabelKey from "@/utils/chargeModeLabel";
import deepClone from "@/utils/deepClone";
import deepEqual from "@/utils/deepEqual";
import sleep from "@/utils/sleep";
Expand All @@ -675,7 +676,7 @@ import {

const nsPerMin = 60 * 1e9;

const { OFF, PV, MINPV, NOW } = CHARGE_MODE;
const { OFF, SMART, NOW } = CHARGE_MODE;

const defaultValues = {
id: undefined,
Expand Down Expand Up @@ -805,6 +806,9 @@ export default {
chargerIsSwitchDevice() {
return this.chargerStatus?.switchDevice?.value || false;
},
chargerIsContinuous() {
return this.chargerStatus?.continuous?.value || false;
},
chargerIsHeating() {
return this.chargerStatus?.heating?.value === true;
},
Expand Down Expand Up @@ -844,9 +848,12 @@ export default {
},
defaultModeOptions(): { key: CHARGE_MODE; name: string }[] {
// empty option is provided by PropertyField placeholder
// switch devices have no current control, so minpv does not apply
const modes = this.chargerIsSwitchDevice ? [OFF, PV, NOW] : [OFF, PV, MINPV, NOW];
return modes.map((key) => ({ key, name: this.$t(`main.mode.${key}`) }));
return [OFF, SMART, NOW].map((key) => ({
key,
name: this.$t(
chargeModeLabelKey(key, this.chargerIsContinuous, this.chargerIsSwitchDevice)
),
}));
},
showCircuit() {
return this.circuits.length > 0 || !!this.values.circuit;
Expand Down
3 changes: 1 addition & 2 deletions assets/js/components/Config/PropertyField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -339,8 +339,7 @@ export default {
if (this.chargeModes) {
return [
{ key: "off", name: this.$t("main.mode.off") },
{ key: "pv", name: this.$t("main.mode.pv") },
{ key: "minpv", name: this.$t("main.mode.minpv") },
{ key: "smart", name: this.$t("main.mode.smart") },
{ key: "now", name: this.$t("main.mode.now") },
];
}
Expand Down
151 changes: 151 additions & 0 deletions assets/js/components/Loadpoints/AlwaysChargeDropdown.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
<template>
<div class="panel" data-testid="always-charge-dropdown">
<div class="d-flex align-items-center gap-3">
<AlwaysChargeIcon class="icon flex-shrink-0" :class="{ active: isActive }" />
<div class="flex-grow-1 overflow-hidden">
<div class="fw-bold text-truncate">{{ $t("main.alwaysCharge.label") }}</div>
<div class="subline" :class="{ hint: !!hint }">{{ subline }}</div>
</div>
<div class="d-flex align-items-center gap-2 flex-shrink-0">
<button
type="button"
class="once-badge"
:class="{ active: isOnce }"
:title="$t('main.alwaysCharge.onceTip')"
:aria-label="$t('main.alwaysCharge.onceTip')"
:aria-pressed="isOnce"
@click="toggleOnce"
>
<OnceIcon :filled="isOnce" />
</button>
<div class="form-check form-switch m-0">
<input
class="form-check-input"
type="checkbox"
role="switch"
:checked="isActive"
:aria-label="$t('main.alwaysCharge.label')"
@change="toggle"
/>
</div>
</div>
</div>
</div>
</template>

<script lang="ts">
import { defineComponent } from "vue";
import { ALWAYS_CHARGE, type Timeout } from "@/types/evcc";
import AlwaysChargeIcon from "../MaterialIcon/AlwaysCharge.vue";
import OnceIcon from "../MaterialIcon/Once.vue";
import formatter from "@/mixins/formatter";

const { OFF, ON, ONCE } = ALWAYS_CHARGE;

export default defineComponent({
name: "AlwaysChargeDropdown",
components: { AlwaysChargeIcon, OnceIcon },
mixins: [formatter],
props: {
alwaysCharge: { type: String, default: OFF },
minCurrent: { type: Number, default: 0 },
},
emits: ["updated"],
data() {
return {
hint: null as "once" | "permanent" | null,
timeout: null as Timeout | null,
};
},
computed: {
isActive() {
return this.alwaysCharge === ON || this.alwaysCharge === ONCE;
},
isOnce() {
return this.alwaysCharge === ONCE;
},
subline() {
if (this.hint) {
return this.$t(`main.alwaysCharge.${this.hint}`);
}
return this.$t("main.alwaysCharge.description", {
current: this.fmtNumber(this.minCurrent, this.minCurrent % 1 ? 1 : 0),
});
},
},
unmounted() {
this.clearHint();
},
methods: {
toggle() {
this.clearHint();
this.$emit("updated", this.isActive ? OFF : ON);
},
toggleOnce() {
const newValue = this.isOnce ? ON : ONCE;
// briefly explain the new scope, then revert to the regular description
this.showHint(newValue === ONCE ? "once" : "permanent");
this.$emit("updated", newValue);
},
showHint(hint: "once" | "permanent") {
this.hint = hint;
if (this.timeout) clearTimeout(this.timeout);
this.timeout = setTimeout(() => this.clearHint(), 5000);
},
clearHint() {
this.hint = null;
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = null;
}
},
},
});
</script>

<style scoped>
.panel {
--highlight: var(--evcc-darker-green);
position: absolute;
top: calc(100% + 10px);
right: 0;
z-index: 5;
width: 320px;
max-width: calc(100vw - 2rem);
padding: 0.75rem 0.875rem;
background: var(--evcc-box);
border-radius: 1rem;
box-shadow: 0 0 8px var(--bs-gray-light);
text-align: start;
}
html.dark .panel {
/* brighter green for contrast on dark panel */
--highlight: var(--evcc-dark-green);
}
.icon {
color: var(--evcc-gray);
}
.icon.active {
color: var(--highlight);
}
.subline {
font-size: var(--bs-body-font-size);
color: var(--evcc-gray);
}
.subline.hint {
color: var(--highlight);
}
.once-badge {
padding: 0;
color: var(--evcc-gray);
background: transparent;
border: none;
}
.once-badge.active {
color: var(--highlight);
}
.form-check-input:checked {
background-color: var(--highlight);
border-color: var(--highlight);
}
</style>
20 changes: 15 additions & 5 deletions assets/js/components/Loadpoints/BatteryBoostButton.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,20 @@ interface RowConfig {
}

const rows: RowConfig[] = [
{ label: "Ready (limit 0)", batteryBoost: false, batteryBoostLimit: 0, mode: CHARGE_MODE.PV },
{ label: "Enabled (limit 0)", batteryBoost: true, batteryBoostLimit: 0, mode: CHARGE_MODE.PV },
{ label: "Ready (limit 50)", batteryBoost: false, batteryBoostLimit: 50, mode: CHARGE_MODE.PV },
{ label: "Enabled (limit 50)", batteryBoost: true, batteryBoostLimit: 50, mode: CHARGE_MODE.PV },
{ label: "Ready (limit 0)", batteryBoost: false, batteryBoostLimit: 0, mode: CHARGE_MODE.SMART },
{ label: "Enabled (limit 0)", batteryBoost: true, batteryBoostLimit: 0, mode: CHARGE_MODE.SMART },
{
label: "Ready (limit 50)",
batteryBoost: false,
batteryBoostLimit: 50,
mode: CHARGE_MODE.SMART,
},
{
label: "Enabled (limit 50)",
batteryBoost: true,
batteryBoostLimit: 50,
mode: CHARGE_MODE.SMART,
},
{ label: "Disabled", batteryBoost: false, batteryBoostLimit: 50, mode: CHARGE_MODE.OFF },
];

Expand Down Expand Up @@ -74,5 +84,5 @@ Playground.args = {
batteryBoost: false,
batteryBoostLimit: 50,
batterySoc: 75,
mode: CHARGE_MODE.PV,
mode: CHARGE_MODE.SMART,
};
2 changes: 1 addition & 1 deletion assets/js/components/Loadpoints/Loadpoint.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export default {
const baseState = {
id: "1",
title: "Carport",
mode: CHARGE_MODE.PV,
mode: CHARGE_MODE.SMART,
enabled: true,
connected: true,
socBasedCharging: true,
Expand Down
13 changes: 12 additions & 1 deletion assets/js/components/Loadpoints/Loadpoint.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@
/>
</div>
<div class="mb-3 d-flex align-items-center">
<Mode class="flex-grow-1" v-bind="modeProps" @updated="setTargetMode" />
<Mode
class="flex-grow-1"
v-bind="modeProps"
@updated="setTargetMode"
@always-charge-updated="setAlwaysCharge"
/>
<LoadpointSettingsButton
:id="id"
:class="expandLoadpointHeader ? 'd-lg-none d-xl-block' : ''"
Expand Down Expand Up @@ -120,6 +125,7 @@ import SessionInfo from "./SessionInfo.vue";
import { defineComponent, type PropType } from "vue";
import type {
CHARGE_MODE,
ALWAYS_CHARGE,
PHASE_ACTION,
PV_ACTION,
CHARGER_STATUS_REASON,
Expand Down Expand Up @@ -152,6 +158,8 @@ export default defineComponent({
// main
title: String,
mode: String as PropType<CHARGE_MODE>,
alwaysCharge: String as PropType<ALWAYS_CHARGE>,
effectiveMinCurrent: Number,
effectiveLimitSoc: Number,
effectiveMinSoc: Number,
limitEnergy: Number,
Expand Down Expand Up @@ -383,6 +391,9 @@ export default defineComponent({
setTargetMode(mode: CHARGE_MODE) {
api.post(this.apiPath("mode") + "/" + mode);
},
setAlwaysCharge(value: ALWAYS_CHARGE) {
api.post(this.apiPath("alwayscharge") + "/" + value);
},
setLimitSoc(soc: number) {
api.post(this.apiPath("limitsoc") + "/" + soc);
},
Expand Down
Loading
Loading