Config UI: add tariffs (#26698)

Co-authored-by: Maschga <88616799+Maschga@users.noreply.github.com>
Co-authored-by: andig <cpuidle@gmail.com>
Co-authored-by: andig <andi@evcc.io>
This commit is contained in:
Michael Geers 2026-03-01 12:06:34 +01:00 • committed by GitHub
parent ba159992fd
commit a4ed1080e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 2711 additions and 310 deletions

View file

@ -2,6 +2,7 @@ package globalconfig
import (
"encoding/json"
"iter"
"net"
"os"
"strconv"
@ -173,6 +174,34 @@ type Tariffs struct {
Solar []config.Typed
}
func (c Tariffs) IsConfigured() bool {
return c.Currency != "" || c.Grid.Type != "" || c.FeedIn.Type != "" || c.Co2.Type != "" || c.Planner.Type != "" || len(c.Solar) > 0
}
type TariffRefs struct {
Grid string `json:"grid"`
FeedIn string `json:"feedIn"`
Co2 string `json:"co2"`
Planner string `json:"planner"`
Solar []string `json:"solar"`
}
func (refs TariffRefs) IsConfigured() bool {
return refs.Grid != "" || refs.FeedIn != "" || refs.Co2 != "" || refs.Planner != "" || len(refs.Solar) > 0
}
func (refs TariffRefs) Used() iter.Seq[string] {
return func(yield func(string) bool) {
for _, ref := range append([]string{refs.Grid, refs.FeedIn, refs.Co2, refs.Planner}, refs.Solar...) {
if ref != "" {
if !yield(ref) {
return
}
}
}
}
}
type Network struct {
Schema_ string `json:"schema,omitempty" mapstructure:"schema"` // TODO deprecated
ExternalUrl string `json:"externalUrl"`

View file

@ -72,6 +72,9 @@
--bs-warning: var(--evcc-orange);
--bs-warning-rgb: var(--evcc-orange-rgb);
--bs-warning-bg-subtle: color-mix(in srgb, var(--evcc-orange) 10%, transparent);
--bs-warning-border-subtle: color-mix(in srgb, var(--evcc-orange) 30%, transparent);
--bs-warning-text-emphasis: var(--evcc-orange);
--bs-danger-50: #dc354580;
--bs-danger: var(--evcc-red);

View file

@ -184,7 +184,7 @@ export default defineComponent({
return this.dataChanged && this.selectedActive;
},
weekdaysLabel(): string {
return this.getShortenedWeekdaysLabel(this.selectedWeekdays);
return this.fmtWeekdaysRange(this.selectedWeekdays);
},
socOptions(): SelectOption<number>[] {
// a list of entries from 5 to 100 with a step of 5

View file

@ -0,0 +1,121 @@
<template>
<GenericModal
id="currencyModal"
ref="modal"
:title="$t('config.currency.title')"
data-testid="currency-modal"
config-modal-name="currency"
@open="open"
>
<p>{{ $t("config.currency.description") }}</p>
<p v-if="error" class="text-danger">{{ error }}</p>
<form ref="form" class="container mx-0 px-0" @submit.prevent="save">
<FormRow id="currency" :label="$t('config.currency.label')" :example="exampleText">
<select id="currency" v-model="selectedCurrency" class="form-select" required>
<option
v-for="currency in currencies"
:key="currency.code"
:value="currency.code"
>
{{ currency.code }} - {{ currency.name }}
</option>
</select>
</FormRow>
<div class="mt-4 d-flex justify-content-between gap-2 flex-column flex-sm-row">
<button
type="button"
class="btn btn-link text-muted btn-cancel"
data-bs-dismiss="modal"
>
{{ $t("config.general.cancel") }}
</button>
<button
type="submit"
class="btn btn-primary order-1 order-sm-2 flex-grow-1 flex-sm-grow-0 px-4"
:disabled="saving || !changed"
>
<span
v-if="saving"
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
></span>
{{ $t("config.general.save") }}
</button>
</div>
</form>
</GenericModal>
</template>
<script>
import GenericModal from "../Helper/GenericModal.vue";
import FormRow from "./FormRow.vue";
import store from "@/store";
import api from "@/api";
import { CURRENCY } from "@/types/evcc";
import formatter from "@/mixins/formatter";
export default {
name: "CurrencyModal",
components: { FormRow, GenericModal },
mixins: [formatter],
emits: ["changed"],
data() {
return {
saving: false,
error: "",
selectedCurrency: "EUR",
initialCurrency: "EUR",
};
},
computed: {
currencies() {
return Object.values(CURRENCY).map((code) => ({
code,
name: this.fmtCurrencyName(code),
}));
},
changed() {
return this.selectedCurrency !== this.initialCurrency;
},
exampleText() {
const price = this.fmtPricePerKWh(0.122, this.selectedCurrency);
const amount = this.fmtMoney(20.2, this.selectedCurrency, true, true);
return this.$t("config.currency.example", { price, amount });
},
},
methods: {
reset() {
const currency = store?.state?.currency || "EUR";
this.saving = false;
this.error = "";
this.selectedCurrency = currency;
this.initialCurrency = currency;
},
async open() {
this.reset();
},
async save() {
this.saving = true;
this.error = "";
try {
await api.put("/config/currency", JSON.stringify(this.selectedCurrency));
this.$emit("changed");
this.$refs.modal.close();
} catch (e) {
this.error = e.message;
}
this.saving = false;
},
},
};
</script>
<style scoped>
.container {
margin-left: calc(var(--bs-gutter-x) * -0.5);
margin-right: calc(var(--bs-gutter-x) * -0.5);
padding-right: 0;
}
</style>

View file

@ -4,6 +4,7 @@
v-if="testState"
v-bind="testState"
:sponsor-token-required="sponsorTokenRequired"
:currency="currency"
@test="$emit('test')"
/>
@ -51,6 +52,7 @@ import { defineComponent } from "vue";
import type { PropType } from "vue";
import TestResult from "../TestResult.vue";
import { type TestState } from "../utils/test";
import type { CURRENCY } from "@/types/evcc";
export default defineComponent({
name: "DeviceModalActions",
@ -67,6 +69,7 @@ export default defineComponent({
isSucceeded: Boolean as PropType<boolean>,
isNew: Boolean as PropType<boolean>,
sponsorTokenRequired: Boolean as PropType<boolean>,
currency: String as PropType<CURRENCY>,
},
emits: ["save", "remove", "test"],
computed: {

View file

@ -58,6 +58,7 @@
v-bind="param"
v-model="values[param.Name]"
:service-values="serviceValues[param.Name]"
:currency="currency"
/>
<div v-if="auth.code">
@ -129,6 +130,7 @@
v-bind="param"
v-model="values[param.Name]"
:service-values="serviceValues[param.Name]"
:currency="currency"
/>
<PropertyCollapsible>
@ -140,6 +142,7 @@
v-bind="param"
v-model="values[param.Name]"
:service-values="serviceValues[param.Name]"
:currency="currency"
/>
</template>
<template v-if="$slots['collapsible-more']" #more>
@ -158,6 +161,7 @@
:is-succeeded="succeeded"
:is-new="isNew"
:sponsor-token-required="sponsorTokenRequired"
:currency="currency"
@save="handleSave"
@remove="handleRemove"
@test="testManually"
@ -188,6 +192,7 @@ import { initialAuthState, prepareAuthLogin } from "../utils/authProvider";
import sleep from "@/utils/sleep";
import { ConfigType } from "@/types/evcc";
import type { DeviceType, Timeout } from "@/types/evcc";
import { CURRENCY } from "@/types/evcc";
import {
handleError,
type DeviceValues,
@ -235,6 +240,7 @@ export default defineComponent({
showMainContent: { type: Boolean, default: true },
// Optional: usage parameter for loadProducts (e.g., meter type: "pv", "battery", "aux", "ext")
usage: String,
currency: { type: String as PropType<CURRENCY>, default: CURRENCY.EUR },
// Optional: custom product name computation
getProductName: Function as PropType<
(values: DeviceValues, templateName: string | null) => string
@ -459,7 +465,6 @@ export default defineComponent({
this.$emit("update:externalTemplate", newValue);
}
console.log("templateName changed", { newValue, oldValue });
// Reset values when template changes (except on initial load or when switching to YAML input)
// YAML input types set values.type and values.yaml in handleTemplateChange callback
if (oldValue != null) {
@ -505,7 +510,6 @@ export default defineComponent({
},
values: {
handler() {
this.test = initialTestState();
this.updateServiceValues();
},
deep: true,
@ -676,7 +680,6 @@ export default defineComponent({
return this.device.test(this.id, this.apiData);
},
async update(force = false) {
console.log("update called", { force, isUnknown: this.test.isUnknown, id: this.id });
if (this.test.isUnknown && !force) {
const success = await performTest(
this.test,
@ -690,7 +693,6 @@ export default defineComponent({
}
this.saving = true;
try {
console.log("calling device.update", this.apiData);
await this.device.update(this.id!, this.apiData, force);
this.saving = false;
this.succeeded = true;

View file

@ -1,84 +1,176 @@
<template>
<div v-if="tags" class="tags">
<span
v-for="(entry, index) in regularEntries"
:key="index"
:data-testid="`device-tag-${entry.name}`"
class="d-flex gap-2 overflow-hidden text-truncate"
>
<div class="label overflow-hidden text-truncate flex-shrink-1 flex-grow-1">
{{ $t(`config.deviceValue.${entry.name}`) }}
</div>
<div class="value overflow-hidden text-truncate" :class="valueClasses(entry)">
{{ fmtDeviceValue(entry) }}
</div>
</span>
<table v-if="hasPhaseEntries" class="table table-borderless table-sm my-0">
<thead>
<tr>
<th></th>
<th class="small evcc-gray text-end ps-2">L1</th>
<th class="small evcc-gray text-end ps-2">L2</th>
<th class="small evcc-gray text-end ps-2">L3</th>
<th></th>
</tr>
</thead>
<tbody>
<tr
v-for="(entry, index) in phaseEntries"
:key="index"
:data-testid="`device-tag-${entry.name}`"
>
<td class="text-truncate">
{{ $t(`config.deviceValue.${entry.name}`) }}
</td>
<td
v-for="(val, idx) in entry.value"
:key="idx"
class="value text-end tabular ps-2"
:class="valueClasses(entry)"
<div v-if="tags">
<div class="tags">
<span
v-for="(entry, index) in regularEntries"
:key="index"
:data-testid="`device-tag-${entry.name}`"
class="d-flex gap-2 overflow-hidden text-truncate"
>
<div class="label overflow-hidden text-truncate flex-shrink-1 flex-grow-1">
{{ $t(`config.deviceValue.${entry.name}`) }}
</div>
<div class="value overflow-hidden text-truncate" :class="valueClasses(entry)">
{{ fmtDeviceValue(entry) }}
</div>
</span>
<table v-if="hasPhaseEntries" class="table table-borderless table-sm my-0">
<thead>
<tr>
<th></th>
<th class="small evcc-gray text-end ps-2">L1</th>
<th class="small evcc-gray text-end ps-2">L2</th>
<th class="small evcc-gray text-end ps-2">L3</th>
<th></th>
</tr>
</thead>
<tbody>
<tr
v-for="(entry, index) in phaseEntries"
:key="index"
:data-testid="`device-tag-${entry.name}`"
>
{{ fmtPhaseValue(entry.name, val) }}
</td>
<td class="value unit-col ps-1" :class="valueClasses(entry)">
{{ getPhaseUnit(entry.name) }}
</td>
</tr>
</tbody>
</table>
<td class="text-truncate">
{{ $t(`config.deviceValue.${entry.name}`) }}
</td>
<td
v-for="(val, idx) in entry.value"
:key="idx"
class="value text-end tabular ps-2"
:class="valueClasses(entry)"
>
{{ fmtPhaseValue(entry.name, val) }}
</td>
<td class="value unit-col ps-1" :class="valueClasses(entry)">
{{ getPhaseUnit(entry.name) }}
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="ratesSlots.length" class="forecast-section mt-2">
<div class="d-flex justify-content-between mb-2">
<div class="label">{{ $t("config.deviceValue.forecast") }}</div>
<div class="text-end">
<div v-if="activeSlot" class="value text-primary">
{{ activeSlotCost }}
</div>
<div v-else class="value text-primary">
{{ fmtRatesCostRange }}
</div>
</div>
</div>
<div class="forecast-chart-container">
<TariffChart :slots="ratesSlots" @slot-hovered="slotHovered" />
</div>
</div>
</div>
</template>
<script>
import formatter, { POWER_UNIT } from "@/mixins/formatter";
import TariffChart from "../Tariff/TariffChart.vue";
import { generateRateSlots, calculateCostRange } from "@/utils/tariffSlots";
const HIDDEN_TAGS = ["icon", "heating", "integratedDevice"];
const PHASE_TAGS = ["phaseCurrents", "phaseVoltages", "phasePowers"];
const FORECAST_TAGS = ["priceRates", "co2Rates", "solarRates"];
export default {
name: "DeviceTags",
components: { TariffChart },
mixins: [formatter],
props: {
tags: Object,
currency: String,
},
data() {
return {
activeIndex: null,
};
},
computed: {
regularEntries() {
return Object.entries(this.tags)
.filter(([name]) => !HIDDEN_TAGS.includes(name) && !PHASE_TAGS.includes(name))
.map(([name, { value, error, warning, muted, options }]) => {
return { name, value, error, warning, muted, options };
.filter(
([name]) =>
!HIDDEN_TAGS.includes(name) &&
!PHASE_TAGS.includes(name) &&
!FORECAST_TAGS.includes(name)
)
.map(([name, { value, error, warning, muted }]) => {
return { name, value, error, warning, muted };
});
},
phaseEntries() {
return Object.entries(this.tags)
.filter(([name]) => PHASE_TAGS.includes(name))
.map(([name, { value, error, warning, muted, options }]) => {
return { name, value, error, warning, muted, options };
.map(([name, { value, error, warning, muted }]) => {
return { name, value, error, warning, muted };
});
},
hasPhaseEntries() {
return this.phaseEntries.length > 0;
},
ratesEntry() {
if (!this.tags) return null;
const typeMap = {
priceRates: "price",
co2Rates: "co2",
solarRates: "solar",
};
// Find which forecast tag is present
for (const tagName of FORECAST_TAGS) {
if (this.tags[tagName]) {
const { value, error } = this.tags[tagName];
return { name: tagName, type: typeMap[tagName], value, error };
}
}
return null;
},
rates() {
if (!this.ratesEntry?.value?.length) return [];
return this.ratesEntry.value.map((rate) => ({
start: new Date(rate.start),
end: new Date(rate.end),
value: rate.value,
}));
},
ratesSlots() {
return generateRateSlots(this.rates, this.weekdayShort);
},
activeSlot() {
return this.activeIndex !== null ? this.ratesSlots[this.activeIndex] || null : null;
},
activeSlotCost() {
const value = this.activeSlot?.value;
if (value === undefined) {
return "–";
}
return this.formatRateValue(value, true);
},
fmtRatesCostRange() {
const slots = this.ratesSlots.filter((s) => s.value !== undefined);
if (slots.length === 0) return "";
const { min, max } = calculateCostRange(slots);
if (min === undefined || max === undefined) return "";
const fmtMax = this.formatRateValue(max, true);
// For solar rates, show only max value
if (this.ratesEntry?.type === "solar") {
return `${this.$t("config.deviceValue.max")} ${fmtMax}`;
}
// For price and CO2 rates, show range
const fmtMin = this.formatRateValue(min, true);
return `${fmtMin} – ${fmtMax}`;
},
},
methods: {
valueClasses(entry) {
@ -89,7 +181,7 @@ export default {
};
},
fmtDeviceValue(entry) {
const { name, value, options = {} } = entry;
const { name, value } = entry;
if (value === null || value === undefined) {
return "";
}
@ -113,9 +205,10 @@ export default {
return `${this.fmtNumber(value, 0)} km`;
case "chargeStatus":
return value ? this.$t(`config.deviceValue.chargeStatus${value}`) : "-";
case "price":
case "gridPrice":
case "feedinPrice":
return this.fmtPricePerKWh(value, options.currency, true);
return this.fmtPricePerKWh(value, this.currency, true);
case "co2":
return this.fmtCo2Short(value);
case "powerRange":
@ -161,6 +254,22 @@ export default {
}
return "";
},
formatRateValue(value, short = false) {
const type = this.ratesEntry?.type;
switch (type) {
case "price":
return this.fmtPricePerKWh(value, this.currency, short);
case "co2":
return short ? this.fmtCo2Short(value) : this.fmtCo2Medium(value);
case "solar":
return this.fmtW(value);
default:
return value;
}
},
slotHovered(index) {
this.activeIndex = index;
},
},
};
</script>
@ -198,4 +307,8 @@ table td {
.unit-col {
width: 0.1%;
}
.forecast-chart-container {
overflow-x: auto;
overflow-y: hidden;
}
</style>

View file

@ -58,10 +58,19 @@
:text="controlStatus"
@edit="openModal('control')"
/>
<GeneralConfigEntry
test-id="generalconfig-currency"
:label="$t('config.currency.title')"
:text="currency"
@edit="openModal('currency')"
/>
<CurrencyModal @changed="$emit('site-changed')" />
</div>
</template>
<script>
import CurrencyModal from "./CurrencyModal.vue";
import GeneralConfigEntry from "./GeneralConfigEntry.vue";
import { openModal } from "@/configModal";
import store from "@/store";
@ -69,7 +78,7 @@ import formatter from "@/mixins/formatter";
export default {
name: "GeneralConfig",
components: { GeneralConfigEntry },
components: { CurrencyModal, GeneralConfigEntry },
mixins: [formatter],
props: {
sponsorError: Boolean,
@ -90,6 +99,9 @@ export default {
const sec = store.state?.interval;
return sec ? this.fmtDuration(sec) : "";
},
currency() {
return store.state?.currency || "EUR";
},
sponsorStatus() {
const sponsor = store.state?.sponsor || {};
const name = sponsor.status?.name;

View file

@ -19,6 +19,7 @@
:choice="Choice"
:service-values="serviceValues"
:label="label"
:currency="currency"
/>
</FormRow>
</template>
@ -46,6 +47,7 @@ export default {
Choice: Array,
serviceValues: Array,
modelValue: [String, Number, Boolean, Object],
currency: { type: String, default: "EUR" },
},
emits: ["update:modelValue"],
computed: {

View file

@ -78,6 +78,7 @@
:rows="rows || 4"
:disabled="disabled"
/>
<PropertyZonesField v-else-if="zones" :id="id" v-model="value" :currency="currency" />
<div v-else class="d-flex" :class="sizeClass">
<div class="position-relative flex-grow-1">
<input
@ -126,13 +127,14 @@
import "@h2d2/shopicons/es/regular/minus";
import VehicleIcon from "../VehicleIcon";
import SelectGroup from "../Helper/SelectGroup.vue";
import PropertyZonesField from "./PropertyZonesField.vue";
import formatter from "@/mixins/formatter";
const NS_PER_SECOND = 1000000000;
export default {
name: "PropertyField",
components: { VehicleIcon, SelectGroup },
components: { VehicleIcon, SelectGroup, PropertyZonesField },
mixins: [formatter],
props: {
id: String,
@ -151,6 +153,7 @@ export default {
modelValue: [String, Number, Boolean, Object],
label: String,
serviceValues: { type: Array, default: () => [] },
currency: { type: String, default: "EUR" },
rows: { type: Number },
},
emits: ["update:modelValue"],
@ -193,7 +196,7 @@ export default {
if (this.masked) {
return "password";
}
if (["Int", "Float", "Duration"].includes(this.type)) {
if (["Int", "Float", "Duration", "PricePerKWh"].includes(this.type)) {
return "number";
}
return "text";
@ -202,7 +205,7 @@ export default {
if (this.size) {
return this.size;
}
if (["Int", "Float", "Duration"].includes(this.type)) {
if (["Int", "Float", "Duration", "PricePerKWh"].includes(this.type)) {
return "w-50 w-min-200";
}
return "";
@ -218,10 +221,10 @@ export default {
return result;
},
endAlign() {
return ["Int", "Float", "Duration"].includes(this.type);
return ["Int", "Float", "Duration", "PricePerKWh"].includes(this.type);
},
step() {
if (this.type === "Float" || this.type === "Duration") {
if (this.type === "Float" || this.type === "Duration" || this.type === "PricePerKWh") {
return "any";
}
return null;
@ -230,11 +233,18 @@ export default {
if (this.type === "Duration") {
return this.fmtDurationUnit(this.value, this.unit);
}
if (this.pricePerKWh) {
return this.pricePerKWhUnit(this.currency);
}
if (this.unit) {
return this.unit;
}
return null;
},
useLazyBinding() {
// avoid conversion loop issues
return this.pricePerKWh;
},
icons() {
return this.property === "icon";
},
@ -251,6 +261,12 @@ export default {
array() {
return this.type === "List";
},
zones() {
return this.type === "Zones";
},
pricePerKWh() {
return this.type === "PricePerKWh";
},
select() {
return this.choice.length > 0;
},
@ -298,6 +314,12 @@ export default {
return this.modelValue / this.durationFactor / NS_PER_SECOND;
}
if (this.pricePerKWh) {
const value = this.modelValue * this.pricePerKWhDisplayFactor(this.currency);
// Round to 6 decimals to eliminate floating-point errors
return Math.round(value * 1e6) / 1e6;
}
return this.modelValue;
},
set(value) {
@ -315,6 +337,10 @@ export default {
newValue = newValue * this.durationFactor * NS_PER_SECOND;
}
if (this.pricePerKWh) {
newValue = value / this.pricePerKWhDisplayFactor(this.currency);
}
this.$emit("update:modelValue", newValue);
},
},

View file

@ -0,0 +1,232 @@
<template>
<div class="border rounded p-3">
<!-- Price input -->
<div class="mb-3">
<label :for="formId('price')" class="form-label">{{
$t("config.tariff.zones.price")
}}</label>
<div class="d-flex w-50 w-min-200">
<input
:id="formId('price')"
v-model.number.lazy="uiZone.price"
type="number"
step="any"
class="form-control text-end"
:class="{ 'is-invalid': isPriceInvalid }"
style="border-top-right-radius: 0; border-bottom-right-radius: 0"
/>
<span
class="input-group-text"
:class="{ 'border-danger': isPriceInvalid }"
style="border-top-left-radius: 0; border-bottom-left-radius: 0"
>{{ priceUnit }}</span
>
</div>
<div v-if="isPriceInvalid" class="invalid-feedback d-block">
{{ $t("config.tariff.zones.priceRequired") }}
</div>
</div>
<!-- Month selector -->
<div class="mb-3">
<label :for="formId('months')" class="form-label">
{{ $t("config.tariff.zones.months") }}
<small class="evcc-gray">{{ $t("config.form.optional") }}</small>
</label>
<MultiSelect
:id="formId('months')"
v-model="uiZone.months"
:options="monthOptions"
:selectAllLabel="$t('main.chargingPlan.selectAll')"
>
{{ monthsLabel(uiZone.months) }}
</MultiSelect>
</div>
<!-- Weekday selector -->
<div class="mb-3">
<label :for="formId('weekdays')" class="form-label">
{{ $t("config.tariff.zones.weekdays") }}
<small class="evcc-gray">{{ $t("config.form.optional") }}</small>
</label>
<MultiSelect
:id="formId('weekdays')"
v-model="uiZone.weekdays"
:options="dayOptions"
:selectAllLabel="$t('main.chargingPlan.selectAll')"
>
{{ weekdaysLabel(uiZone.weekdays) }}
</MultiSelect>
</div>
<!-- Time range -->
<div class="mb-3">
<label class="form-label">
{{ $t("config.tariff.zones.hours") }}
<small class="evcc-gray">{{ $t("config.form.optional") }}</small>
</label>
<div class="d-flex gap-2 align-items-center">
<input
:id="formId('time-from')"
v-model="uiZone.timeFrom"
type="time"
class="form-control"
:class="{ 'is-invalid': isTimeRangeInvalid }"
:aria-label="$t('config.tariff.zones.timeFrom')"
/>
<span>–</span>
<input
:id="formId('time-to')"
v-model="uiZone.timeTo"
type="time"
class="form-control"
:class="{ 'is-invalid': isTimeRangeInvalid }"
:aria-label="$t('config.tariff.zones.timeTo')"
/>
</div>
<div v-if="isTimeRangeInvalid" class="invalid-feedback d-block">
{{ $t("config.tariff.zones.timeRangeError") }}
</div>
</div>
<!-- Actions -->
<div class="d-flex justify-content-between align-items-center">
<button type="button" class="btn btn-link text-muted" @click="$emit('cancel')">
{{ $t("config.tariff.zones.cancel") }}
</button>
<button type="button" class="btn btn-primary" @click="handleSave">
{{ $t("config.tariff.zones.save") }}
</button>
</div>
</div>
</template>
<script lang="ts">
import { type PropType } from "vue";
import zoneUtils from "@/mixins/zoneUtils";
import MultiSelect from "../Helper/MultiSelect.vue";
import { CURRENCY, type Zone } from "@/types/evcc";
type UiZone = {
price: number | null;
weekdays: number[];
months: number[];
timeFrom: string;
timeTo: string;
};
export default {
name: "PropertyZoneForm",
components: { MultiSelect },
mixins: [zoneUtils],
props: {
zone: { type: Object as PropType<Zone>, required: true },
currency: { type: String as PropType<CURRENCY>, required: true },
index: { type: Number, required: true },
},
emits: ["update:zone", "save", "cancel"],
data() {
return {
uiZone: {} as UiZone,
saveAttempted: false,
};
},
computed: {
displayFactor() {
return this.pricePerKWhDisplayFactor(this.currency);
},
priceUnit() {
return this.pricePerKWhUnit(this.currency);
},
dayOptions() {
return this.getWeekdaysList("long");
},
monthOptions() {
return this.getMonthsList("long");
},
isValidPrice() {
const price = this.uiZone.price;
return price !== null && !isNaN(price);
},
isValidTimeRange() {
const { timeFrom, timeTo } = this.uiZone;
// values required
if (!timeFrom || !timeTo) return false;
// end of day
if (timeTo === "00:00") return true;
return timeFrom < timeTo;
},
isTimeRangeInvalid() {
if (!this.saveAttempted) return false;
return !this.isValidTimeRange;
},
isPriceInvalid() {
if (!this.saveAttempted) return false;
return !this.isValidPrice;
},
hasValidationErrors() {
return !this.isValidPrice || !this.isValidTimeRange;
},
},
watch: {
zone: {
handler(newZone: Zone) {
this.uiZone = this.convertToUiZone(newZone);
},
immediate: true,
},
},
methods: {
formId(field: string) {
return `zone-${field}-${this.index}`;
},
convertToUiZone(zone: Zone) {
const [timeFrom, timeTo] = zone.hours.split("-");
return {
price: zone.price != null ? zone.price * this.displayFactor : null,
weekdays: this.parseWeekdaysString(zone.days),
months: this.parseMonthsString(zone.months),
timeFrom: timeFrom || "00:00",
timeTo: timeTo || "00:00",
};
},
convertFromUiZone(uiZone: UiZone) {
const { timeFrom, timeTo } = uiZone;
// Treat empty or "00:00-00:00" as all day (no constraint)
const isAllDay = (!timeFrom && !timeTo) || (timeFrom === "00:00" && timeTo === "00:00");
return {
price: uiZone.price != null ? uiZone.price / this.displayFactor : null,
days: this.formatWeekdaysToString(uiZone.weekdays),
months: this.formatMonthsToString(uiZone.months),
hours: isAllDay ? "" : `${timeFrom}-${timeTo}`,
};
},
handleSave() {
this.saveAttempted = true;
if (!this.hasValidationErrors) {
const convertedZone = this.convertFromUiZone(this.uiZone);
this.$emit("save", convertedZone);
}
},
},
};
</script>
<style scoped>
.w-min-200 {
min-width: min(200px, 100%);
}
/* Hide spinner for number input */
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
appearance: textfield;
}
</style>

View file

@ -0,0 +1,69 @@
<template>
<div class="d-flex align-items-center justify-content-between py-2 ps-3 pe-2 border rounded">
<div class="flex-grow-1">
<span class="fw-semibold">{{ formattedPrice }}</span>
<small class="text-muted ms-2">{{ formattedConstraints }}</small>
</div>
<div class="d-flex">
<button
type="button"
class="btn btn-sm btn-outline-secondary border-0"
:aria-label="$t('config.tariff.zones.edit')"
@click="$emit('edit')"
>
<shopicon-regular-edit size="s" class="flex-shrink-0"></shopicon-regular-edit>
</button>
<button
type="button"
class="btn btn-sm btn-outline-secondary border-0"
:aria-label="$t('config.tariff.zones.remove')"
@click="$emit('remove')"
>
<shopicon-regular-trash size="s" class="flex-shrink-0"></shopicon-regular-trash>
</button>
</div>
</div>
</template>
<script lang="ts">
import { type PropType } from "vue";
import "@h2d2/shopicons/es/regular/trash";
import "@h2d2/shopicons/es/regular/edit";
import zoneUtils from "@/mixins/zoneUtils";
import { CURRENCY, type Zone } from "@/types/evcc";
export default {
name: "PropertyZoneSummary",
mixins: [zoneUtils],
props: {
zone: { type: Object as PropType<Zone>, required: true },
currency: { type: String as PropType<CURRENCY>, required: true },
},
emits: ["edit", "remove"],
computed: {
formattedPrice() {
if (this.zone.price == null) return "—";
return this.fmtPricePerKWh(this.zone.price, this.currency, true);
},
formattedConstraints() {
const parts = [];
if (this.zone.days) {
const weekdays = this.parseWeekdaysString(this.zone.days);
if (weekdays.length > 0 && weekdays.length < 7) {
parts.push(this.weekdaysLabel(weekdays));
}
}
if (this.zone.months) {
const months = this.parseMonthsString(this.zone.months);
if (months.length > 0 && months.length < 12) {
parts.push(this.monthsLabel(months));
}
}
if (this.zone.hours) {
parts.push(this.fmtTimeRange(this.zone.hours));
}
return parts.length > 0 ? parts.join(", ") : this.$t("config.tariff.zones.allTimes");
},
},
};
</script>

View file

@ -0,0 +1,110 @@
<template>
<div class="zones-field">
<div
v-for="(zone, index) in modelValue"
:key="index"
data-testid="property-zone"
class="mb-2"
>
<!-- Summary View (read-only) -->
<PropertyZoneForm
v-if="editIndex === index"
:zone="zone"
:currency="currency"
:index="index"
@save="saveEdit"
@cancel="cancelEdit"
/>
<PropertyZoneSummary
v-else
:zone="zone"
:currency="currency"
@edit="startEdit(index)"
@remove="removeZone(index)"
/>
</div>
<!-- Add zone button -->
<div class="d-flex align-items-center">
<button
type="button"
class="d-flex btn btn-sm btn-outline-secondary border-0 align-items-center gap-2 evcc-gray"
@click="addZone"
>
<shopicon-regular-plus size="s" class="flex-shrink-0"></shopicon-regular-plus>
{{ $t("config.tariff.zones.add") }}
</button>
</div>
</div>
</template>
<script lang="ts">
import { type PropType } from "vue";
import "@h2d2/shopicons/es/regular/plus";
import formatter from "@/mixins/formatter";
import PropertyZoneSummary from "./PropertyZoneSummary.vue";
import PropertyZoneForm from "./PropertyZoneForm.vue";
import { CURRENCY, type Zone } from "@/types/evcc";
export default {
name: "PropertyZonesField",
components: { PropertyZoneSummary, PropertyZoneForm },
mixins: [formatter],
props: {
modelValue: {
type: Array as PropType<Zone[]>,
default: () => [],
},
currency: { type: String as PropType<CURRENCY>, default: CURRENCY.EUR },
},
emits: ["update:modelValue"],
data() {
return {
editIndex: null as number | null,
};
},
methods: {
addZone() {
const currentZones = this.modelValue || [];
const newZones = [...currentZones, { price: 0, hours: "", days: "", months: "" }];
this.$emit("update:modelValue", newZones);
// Automatically start editing the new zone
this.$nextTick(() => {
this.startEdit(newZones.length - 1);
});
},
removeZone(index: number) {
const newZones = this.modelValue.filter((_, i) => i !== index);
this.$emit("update:modelValue", newZones);
},
startEdit(index: number) {
this.editIndex = index;
},
saveEdit(convertedZone: Zone) {
const index = this.editIndex;
if (index === null) return;
const newZones = [...this.modelValue];
newZones[index] = convertedZone;
this.$emit("update:modelValue", newZones);
this.editIndex = null;
},
cancelEdit() {
const index = this.editIndex;
if (index !== null) {
const zone = this.modelValue[index];
if (zone && zone.price === null) {
const newZones = this.modelValue.filter((_, i) => i !== index);
this.$emit("update:modelValue", newZones);
}
}
this.editIndex = null;
},
},
};
</script>
<style scoped>
.zones-field {
width: 100%;
}
</style>

View file

@ -0,0 +1,76 @@
<template>
<DeviceCard
:title="cardTitle"
:name="tariff.name"
:editable="!!tariff.id"
:error="hasError"
:data-testid="`tariff-${tariffType}`"
@edit="$emit('edit', tariffType, tariff.id)"
>
<template #icon>
<component :is="iconComponent" />
</template>
<template #tags>
<DeviceTags :tags="tags" :currency="currency" />
</template>
</DeviceCard>
</template>
<script lang="ts">
import "@h2d2/shopicons/es/regular/invoice";
import "@h2d2/shopicons/es/regular/receivepayment";
import "@h2d2/shopicons/es/regular/eco1";
import "@h2d2/shopicons/es/regular/clock";
import "@h2d2/shopicons/es/regular/sun";
import { type PropType } from "vue";
import type { TariffType, CURRENCY } from "@/types/evcc";
import DeviceCard from "./DeviceCard.vue";
import DeviceTags from "./DeviceTags.vue";
type ConfigTariff = {
id: number;
name: string;
deviceTitle?: string;
config?: {
template?: string;
};
};
export default {
name: "TariffCard",
components: {
DeviceCard,
DeviceTags,
},
props: {
tariff: { type: Object as PropType<ConfigTariff>, required: true },
tariffType: { type: String as PropType<TariffType>, required: true },
hasError: { type: Boolean, default: false },
title: String,
tags: { type: Object, default: () => ({}) },
currency: { type: String as PropType<CURRENCY>, required: true },
},
emits: ["edit"],
computed: {
cardTitle(): string {
if (this.title) {
return this.title;
}
if (this.tariff.deviceTitle) {
return this.tariff.deviceTitle;
}
return this.$t(`config.tariff.type.${this.tariffType}`);
},
iconComponent(): string {
const iconMap: Record<TariffType, string> = {
grid: "shopicon-regular-invoice",
feedIn: "shopicon-regular-receivepayment",
co2: "shopicon-regular-eco1",
planner: "shopicon-regular-clock",
solar: "shopicon-regular-sun",
};
return iconMap[this.tariffType];
},
},
};
</script>

View file

@ -0,0 +1,255 @@
<template>
<DeviceModalBase
:id="id"
name="tariff"
device-type="tariff"
:modal-title="modalTitle"
:provide-template-options="provideTemplateOptions"
:initial-values="initialValues"
:show-main-content="!!tariffType"
:on-template-change="handleTemplateChange"
:currency="currency"
:preserve-on-template-change="preserveFields"
@added="handleAdded"
@updated="handleUpdated"
@removed="handleRemoved"
@close="handleClose"
>
<template #pre-content>
<div v-if="!tariffType" class="d-flex flex-column gap-4">
<NewDeviceButton
v-for="t in typeChoices"
:key="t"
:title="$t(`config.tariff.option.${t}`)"
class="addButton"
@click="selectType(t)"
/>
</div>
</template>
<template #description>
<p v-if="hasDescription" class="mt-0 mb-4">
{{ $t(`config.tariff.${tariffType}.description`) }}
</p>
</template>
<template #before-template="{ values }">
<FormRow
v-if="hasDeviceTitle"
id="tariffParamDeviceTitle"
:label="$t('config.general.title')"
>
<PropertyField
id="tariffParamDeviceTitle"
v-model.trim="values.deviceTitle"
type="String"
size="w-100"
class="me-2"
required
/>
</FormRow>
</template>
</DeviceModalBase>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import DeviceModalBase from "./DeviceModal/DeviceModalBase.vue";
import NewDeviceButton from "./NewDeviceButton.vue";
import FormRow from "./FormRow.vue";
import PropertyField from "./PropertyField.vue";
import { ConfigType, CURRENCY, type TariffType } from "@/types/evcc";
import { customTemplateOption, type TemplateGroup } from "./DeviceModal/TemplateSelector.vue";
import type { Product, DeviceValues } from "./DeviceModal";
import { getModal } from "@/configModal";
import tariffPriceYaml from "./defaultYaml/tariffPrice.yaml?raw";
import tariffCo2Yaml from "./defaultYaml/tariffCo2.yaml?raw";
import tariffSolarYaml from "./defaultYaml/tariffSolar.yaml?raw";
const initialValues = {
type: ConfigType.Template,
deviceTitle: "",
deviceProduct: undefined,
yaml: undefined,
template: null,
};
export default defineComponent({
name: "TariffModal",
components: {
DeviceModalBase,
NewDeviceButton,
FormRow,
PropertyField,
},
props: {
currency: { type: String as PropType<CURRENCY>, default: CURRENCY.EUR },
},
emits: ["changed", "close"],
data() {
return {
initialValues,
selectedType: null as TariffType | null,
preserveFields: ["deviceTitle"],
};
},
computed: {
id(): number | undefined {
return getModal("tariff")?.id;
},
type(): TariffType | undefined {
return getModal("tariff")?.type as TariffType | undefined;
},
typeChoices(): TariffType[] {
return (getModal("tariff")?.choices as TariffType[]) || [];
},
isNew(): boolean {
return this.id === undefined;
},
tariffType(): TariffType | null {
return this.type || this.selectedType;
},
modalTitle(): string {
if (this.isNew) {
if (this.tariffType) {
return this.$t(`config.tariff.${this.tariffType}.titleAdd`);
} else {
return this.$t("config.tariff.titleChoice");
}
}
return this.$t(`config.tariff.${this.tariffType}.titleEdit`);
},
hasDeviceTitle(): boolean {
return this.tariffType === "solar";
},
hasDescription(): boolean {
return !!this.tariffType;
},
},
methods: {
provideTemplateOptions(products: Product[]): TemplateGroup[] {
// Use different custom option text for tariffs vs forecasts
const isForecast = ["co2", "planner", "solar"].includes(this.tariffType || "");
const customLabel = isForecast
? this.$t("config.tariff.customForecast")
: this.$t("config.tariff.customTariff");
// Separate demo/generic templates from real services
const genericTemplates = [
"fixed",
"fixed-zones",
"demo-co2-forecast",
"demo-dynamic-grid",
"demo-solar-forecast",
"energy-charts-api",
];
// Filter products by group
const filterByGroup = (group: string) =>
products.filter((p: Product) => {
const isGeneric = genericTemplates.includes(p.template);
return p.group === group && !isGeneric;
});
// Extract generic templates in order from genericTemplates array
const extractGeneric = (group: string) =>
genericTemplates
.map((template) =>
products.find((p: Product) => p.template === template && p.group === group)
)
.filter((p): p is Product => p !== undefined);
const priceProducts = filterByGroup("price");
const co2Products = filterByGroup("co2");
const solarProducts = filterByGroup("solar");
const priceGeneric = extractGeneric("price");
const co2Generic = extractGeneric("co2");
const solarGeneric = extractGeneric("solar");
// Special handling for planner: show price + co2 services
if (this.tariffType === "planner") {
return [
{
label: "generic",
options: [
...priceGeneric,
...co2Generic,
customTemplateOption(customLabel),
],
},
{
label: "services",
options: priceProducts,
},
{
label: "co2Services",
options: co2Products,
},
];
}
// Map tariff types to product groups
const groupMap: Record<string, { service: Product[]; generic: Product[] }> = {
grid: { service: priceProducts, generic: priceGeneric },
feedIn: { service: priceProducts, generic: priceGeneric },
co2: { service: co2Products, generic: co2Generic },
solar: { service: solarProducts, generic: solarGeneric },
};
const mapped = (this.tariffType && groupMap[this.tariffType]) || {
service: [],
generic: [],
};
return [
{
label: "generic",
options: [...mapped.generic, customTemplateOption(customLabel)],
},
{
label: "services",
options: mapped.service,
},
];
},
handleTemplateChange(e: Event, values: DeviceValues) {
const value = (e.target as HTMLSelectElement).value;
if (value === ConfigType.Custom) {
values.type = ConfigType.Custom;
// Select appropriate YAML template based on tariff type
if (
this.tariffType === "grid" ||
this.tariffType === "feedIn" ||
this.tariffType === "planner"
) {
values.yaml = tariffPriceYaml;
} else if (this.tariffType === "co2") {
values.yaml = tariffCo2Yaml;
} else if (this.tariffType === "solar") {
values.yaml = tariffSolarYaml;
}
}
},
selectType(type: TariffType) {
this.selectedType = type;
},
handleAdded(name: string) {
this.$emit("changed", { action: "added", type: this.tariffType, name });
},
handleUpdated() {
this.$emit("changed", { action: "updated", type: this.tariffType });
},
handleRemoved() {
this.$emit("changed", { action: "removed", type: this.tariffType });
},
handleClose() {
this.selectedType = null;
this.$emit("close");
},
},
});
</script>
<style scoped>
.addButton {
min-height: 6rem;
}
</style>

View file

@ -1,14 +1,21 @@
<template>
<YamlModal
name="tariffs"
:title="$t('config.tariffs.title')"
:description="$t('config.tariffs.description')"
name="tariffsLegacy"
:title="`${$t('config.tariff.title')} (${$t('config.general.legacy')})`"
:description="$t('config.tariff.description')"
docs="/docs/tariffs"
:defaultYaml="defaultYaml"
removeKey="tariffs"
endpoint="/config/tariffs"
data-testid="tariffs-legacy-modal"
@changed="$emit('changed')"
/>
>
<template #afterDescription>
<div class="alert alert-warning my-4" role="alert">
{{ $t("config.tariff.legacyWarning") }}
</div>
</template>
</YamlModal>
</template>
<script>
@ -16,7 +23,7 @@ import YamlModal from "./YamlModal.vue";
import defaultYaml from "./defaultYaml/tariffs.yaml?raw";
export default {
name: "TariffsModal",
name: "TariffsLegacyModal",
components: { YamlModal },
emits: ["changed"],
data() {

View file

@ -32,13 +32,18 @@
</div>
<hr v-if="hasResult" class="divider" />
<div v-if="hasResult" :class="{ 'opacity-25': isRunning }">
<DeviceTags :tags="result as Record<string, any>" class="success-values" />
<DeviceTags
:tags="result as Record<string, any>"
:currency="currency"
class="success-values"
/>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import type { CURRENCY } from "@/types/evcc";
import DeviceTags from "./DeviceTags.vue";
import SponsorTokenRequired from "./DeviceModal/SponsorTokenRequired.vue";
@ -53,6 +58,7 @@ export default defineComponent({
result: Object as PropType<Record<string, any> | null>,
error: String as PropType<string | null>,
sponsorTokenRequired: Boolean,
currency: String as PropType<CURRENCY>,
},
emits: ["test"],
data() {

View file

@ -0,0 +1,28 @@
## CO₂ intensity forecast
tariff: co2
forecast: # hourly CO₂ intensity forecast (g/kWh)
source: js
script: |
var rates = [];
var now = new Date();
for (var i = 0; i < 48; i++) {
var start = new Date(now.getTime() + i * 3600000);
rates.push({
start: start.toISOString(),
end: new Date(start.getTime() + 3600000).toISOString(),
value: 350 // g/kWh
});
}
JSON.stringify(rates);
## HTTP example (uncomment to use)
#forecast: # hourly CO₂ intensity forecast (g/kWh)
# source: http
# uri: https://example.com/api/co2
# jq: |
# map({
# "start": .start,
# "end": .end,
# "value": .co2_intensity
# }) | tostring

View file

@ -0,0 +1,25 @@
## static price
price: # current price
source: const
value: 0.294 # EUR/kWh
## dynamic price (uncomment to use)
#price: # current price
# source: http
# uri: https://example.com/api/price
# jq: .price
## forecast (for dynamic tariffs with hourly prices)
#forecast: # hourly price forecast
# source: http
# uri: https://example.com/api/forecast
# jq: |
# map({
# "start": .start,
# "end": .end,
# "value": .price
# }) | tostring

View file

@ -0,0 +1,28 @@
## PV production forecast
tariff: solar
forecast: # hourly solar production forecast (W)
source: js
script: |
var rates = [];
var now = new Date();
for (var i = 0; i < 48; i++) {
var start = new Date(now.getTime() + i * 3600000);
rates.push({
start: start.toISOString(),
end: new Date(start.getTime() + 3600000).toISOString(),
value: 2000 // W
});
}
JSON.stringify(rates);
## HTTP example (uncomment to use)
#forecast: # hourly solar production forecast (W)
# source: http
# uri: https://example.com/api/solar
# jq: |
# map({
# "start": .start,
# "end": .end,
# "value": .power
# }) | tostring

View file

@ -95,6 +95,7 @@ import formatter from "@/mixins/formatter";
import TariffChart from "./TariffChart.vue";
import { defineComponent, type PropType } from "vue";
import { type CURRENCY, type Rate, type SelectOption, type Slot } from "@/types/evcc";
import { generateRateSlots, calculateCostRange } from "@/utils/tariffSlots";
type LimitDirection = "above" | "below";
type HighlightColor = "text-primary" | "text-warning";
@ -295,23 +296,8 @@ export default defineComponent({
return this.fmtPricePerKWh(value, this.currency);
},
findRateInRange(start: Date, end: Date, rates: Rate[]) {
return rates.find((r) => {
if (r.start.getTime() < start.getTime()) {
return r.end.getTime() > start.getTime();
}
return r.start.getTime() < end.getTime();
});
},
costRange(slots: Slot[]): { min: number | undefined; max: number | undefined } {
let min = undefined as number | undefined;
let max = undefined as number | undefined;
slots.forEach((slot) => {
if (slot.value === undefined) return;
min = min === undefined ? slot.value : Math.min(min, slot.value);
max = max === undefined ? slot.value : Math.max(max, slot.value);
});
return { min, max };
return calculateCostRange(slots);
},
fmtCostRange({ min, max }: { min: number | undefined; max: number | undefined }): string {
if (min === undefined || max === undefined) return "";
@ -326,42 +312,20 @@ export default defineComponent({
return this.fmtPricePerKWh(value, this.currency, true);
},
slotsForLimit(limit: number | null): Slot[] {
if (!this.rates?.length) {
return [];
}
const rates = this.rates;
const quarterHour = 15 * 60 * 1000;
const base = new Date();
base.setSeconds(0, 0);
base.setMinutes(base.getMinutes() - (base.getMinutes() % 15));
return Array.from({ length: 96 * 4 }, (_, i) => {
const start = new Date(base.getTime() + quarterHour * i);
const end = new Date(start.getTime() + quarterHour);
const value = this.findRateInRange(start, end, rates)?.value;
const active =
return generateRateSlots(
this.rates,
this.weekdayShort,
(value) =>
this.limitDirection === "below" &&
limit !== null &&
value !== undefined &&
value <= limit;
const warning =
value <= limit,
(value) =>
this.limitDirection === "above" &&
limit !== null &&
value !== undefined &&
value >= limit;
return {
day: this.weekdayShort(start),
value,
start,
end,
charging: active,
selectable: value !== undefined,
warning,
};
});
value >= limit
);
},
slotHovered(index: number) {
this.activeIndex = index;

View file

@ -175,21 +175,68 @@ describe("getWeekdaysList", () => {
});
});
describe("getShortenedWeekdaysLabel", () => {
describe("getMonthsList", () => {
test("should return the correct month-order", () => {
expect(fmt.getMonthsList("long")).toEqual([
{ name: "Januar", value: 0 },
{ name: "Februar", value: 1 },
{ name: "März", value: 2 },
{ name: "April", value: 3 },
{ name: "Mai", value: 4 },
{ name: "Juni", value: 5 },
{ name: "Juli", value: 6 },
{ name: "August", value: 7 },
{ name: "September", value: 8 },
{ name: "Oktober", value: 9 },
{ name: "November", value: 10 },
{ name: "Dezember", value: 11 },
]);
});
test("should return short month names", () => {
const shortMonths = fmt.getMonthsList("short");
expect(shortMonths).toHaveLength(12);
expect(shortMonths[0]).toEqual({ name: "Jan", value: 0 });
expect(shortMonths[11]).toEqual({ name: "Dez", value: 11 });
});
});
describe("fmtWeekdaysRange", () => {
test("should format single days", () => {
expect(fmt.getShortenedWeekdaysLabel([0])).eq("So");
expect(fmt.getShortenedWeekdaysLabel([0, 2, 4, 6])).eq("Di, Do, Sa, So");
expect(fmt.getShortenedWeekdaysLabel([6])).eq("Sa");
expect(fmt.getShortenedWeekdaysLabel([3, 6])).eq("Mi, Sa");
expect(fmt.fmtWeekdaysRange([0])).eq("So");
expect(fmt.fmtWeekdaysRange([0, 2, 4, 6])).eq("Di, Do, Sa, So");
expect(fmt.fmtWeekdaysRange([6])).eq("Sa");
expect(fmt.fmtWeekdaysRange([3, 6])).eq("Mi, Sa");
});
test("should format ranges", () => {
expect(fmt.getShortenedWeekdaysLabel([1, 2])).eq("Mo, Di");
expect(fmt.getShortenedWeekdaysLabel([0, 1, 2, 3, 4, 5, 6])).eq("Mo – So");
expect(fmt.getShortenedWeekdaysLabel([0, 1, 3, 4, 5])).eq("Mo, Mi – Fr, So");
expect(fmt.fmtWeekdaysRange([1, 2])).eq("Mo, Di");
expect(fmt.fmtWeekdaysRange([0, 1, 2, 3, 4, 5, 6])).eq("Mo – So");
expect(fmt.fmtWeekdaysRange([0, 1, 3, 4, 5])).eq("Mo, Mi – Fr, So");
});
test("should format single days and ranges", () => {
expect(fmt.getShortenedWeekdaysLabel([0, 1, 3, 5, 6])).eq("Mo, Mi, Fr – So");
expect(fmt.getShortenedWeekdaysLabel([0, 2, 3, 5, 6])).eq("Di, Mi, Fr – So");
expect(fmt.fmtWeekdaysRange([0, 1, 3, 5, 6])).eq("Mo, Mi, Fr – So");
expect(fmt.fmtWeekdaysRange([0, 2, 3, 5, 6])).eq("Di, Mi, Fr – So");
});
});
describe("fmtMonthsRange", () => {
test("should format single months", () => {
expect(fmt.fmtMonthsRange([0])).eq("Jan");
expect(fmt.fmtMonthsRange([0, 3, 6, 9])).eq("Jan, Apr, Jul, Okt");
expect(fmt.fmtMonthsRange([11])).eq("Dez");
expect(fmt.fmtMonthsRange([2, 8])).eq("Mär, Sep");
});
test("should format ranges", () => {
expect(fmt.fmtMonthsRange([0, 1])).eq("Jan, Feb");
expect(fmt.fmtMonthsRange([0, 1, 2])).eq("Jan – Mär");
expect(fmt.fmtMonthsRange([9, 10, 11])).eq("Okt – Dez");
expect(fmt.fmtMonthsRange([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])).eq("Jan – Dez");
});
test("should format single months and ranges", () => {
expect(fmt.fmtMonthsRange([0, 1, 2, 5, 9, 10, 11])).eq("Jan – Mär, Jun, Okt – Dez");
expect(fmt.fmtMonthsRange([0, 3, 4, 5, 8])).eq("Jan, Apr – Jun, Sep");
});
test("should handle empty array", () => {
expect(fmt.fmtMonthsRange([])).eq("–");
});
});
@ -226,3 +273,27 @@ describe("12h/24h time format", () => {
expect(fmt.fmtAbsoluteDate(testDate)).toBe("So 15:30");
});
});
describe("fmtTimeRange", () => {
test("should format time ranges in 24h format", () => {
is12hSpy.mockReturnValue(false);
expect(fmt.fmtTimeRange("09:00-17:00")).toBe("9:00 – 17:00");
expect(fmt.fmtTimeRange("06:00-12:00")).toBe("6:00 – 12:00");
});
test("should format time ranges in 12h format", () => {
is12hSpy.mockReturnValue(true);
expect(fmt.fmtTimeRange("09:00-17:00")).toBe("9:00 AM – 5:00 PM");
expect(fmt.fmtTimeRange("00:00-23:59")).toBe("12:00 AM – 11:59 PM");
});
test("should always include minutes", () => {
is12hSpy.mockReturnValue(false);
expect(fmt.fmtTimeRange("09:00-17:00")).toBe("9:00 – 17:00");
expect(fmt.fmtTimeRange("09:15-17:45")).toBe("9:15 – 17:45");
});
test("should handle empty input", () => {
expect(fmt.fmtTimeRange("")).toBe("");
});
});

View file

@ -9,11 +9,16 @@ const CURRENCY_SYMBOLS: Record<CURRENCY, string> = {
CAD: "$",
CHF: "Fr.",
CNY: "¥",
CZK: "Kč",
EUR: "€",
GBP: "£",
HUF: "Ft",
ILS: "₪",
JPY: "¥",
NZD: "$",
NOK: "kr",
PLN: "zł",
RON: "lei",
USD: "$",
DKK: "kr",
SEK: "kr",
@ -29,6 +34,7 @@ const ENERGY_PRICE_IN_SUBUNIT: Partial<Record<CURRENCY, string>> = {
GBP: "p", // GB pence
ILS: "ag", // Israeli agora
NZD: "c", // New Zealand cent
NOK: "øre", // Norwegian øre
PLN: "gr", // Polish grosz
USD: "¢", // US cent
DKK: "øre", // Danish øre
@ -289,15 +295,16 @@ export default defineComponent({
fmtCurrencySymbol(currency = CURRENCY.EUR) {
return CURRENCY_SYMBOLS[currency] || currency;
},
fmtCurrencyName(currency: CURRENCY) {
return (
new Intl.DisplayNames(this.$i18n?.locale, { type: "currency" }).of(currency) || currency
);
},
fmtPricePerKWh(amout = 0, currency = CURRENCY.EUR, short = false, withUnit = true) {
let value = amout;
let minimumFractionDigits = 1;
let maximumFractionDigits = 3;
if (this.energyPriceSubunit(currency)) {
value *= 100;
minimumFractionDigits = 1;
maximumFractionDigits = 1;
}
const factor = this.pricePerKWhDisplayFactor(currency);
const value = amout * factor;
const minimumFractionDigits = 1;
const maximumFractionDigits = this.energyPriceSubunit(currency) ? 1 : 3;
const price = new Intl.NumberFormat(this.$i18n?.locale, {
style: "decimal",
minimumFractionDigits,
@ -315,6 +322,9 @@ export default defineComponent({
const unit = this.energyPriceSubunit(currency) || CURRENCY_SYMBOLS[currency] || currency;
return `${unit}${short ? "" : "/kWh"}`;
},
pricePerKWhDisplayFactor(currency = CURRENCY.EUR) {
return this.energyPriceSubunit(currency) ? 100 : 1;
},
fmtTimeAgo(elapsed: number) {
const units = {
day: 24 * 60 * 60 * 1000,
@ -364,67 +374,108 @@ export default defineComponent({
// TODO: handle fahrenheit
return this.fmtNumber(value, 1, "celsius");
},
getWeekdaysList(
weekdayFormat: Intl.DateTimeFormatOptions["weekday"]
): { name: string; value: number }[] {
const { format } = new Intl.DateTimeFormat(this.$i18n?.locale, {
weekday: weekdayFormat,
});
const mondayToSaturday = [7, 8, 9, 10, 11, 12].map((day, index) => {
return { name: format(new Date(Date.UTC(2021, 5, day))), value: index + 1 };
});
const sunday = { name: format(new Date(Date.UTC(2021, 5, 6))), value: 0 };
return [...mondayToSaturday, sunday];
fmtWeekdayByIndex(index: number, format: Intl.DateTimeFormatOptions["weekday"]) {
// June 7, 2021 is Monday (index 1), June 6 is Sunday (index 0)
const day = index === 0 ? 6 : 6 + index;
return new Intl.DateTimeFormat(this.$i18n?.locale, {
weekday: format,
}).format(new Date(Date.UTC(2021, 5, day)));
},
getShortenedWeekdaysLabel(selectedWeekdays: number[]): string {
if (0 === selectedWeekdays.length) {
fmtMonthByIndex(index: number, format: Intl.DateTimeFormatOptions["month"]) {
return new Intl.DateTimeFormat(this.$i18n?.locale, {
month: format,
}).format(new Date(Date.UTC(2021, index, 1)));
},
getWeekdaysList(
format: Intl.DateTimeFormatOptions["weekday"]
): { name: string; value: number }[] {
return Array.from({ length: 7 }, (_, i) => {
const value = (i + 1) % 7; // Mon=1, Tue=2, ..., Sat=6, Sun=0
return { name: this.fmtWeekdayByIndex(value, format), value };
});
},
getMonthsList(format: Intl.DateTimeFormatOptions["month"]): { name: string; value: number }[] {
return Array.from({ length: 12 }, (_, i) => ({
name: this.fmtMonthByIndex(i, format),
value: i,
}));
},
fmtConsecutiveRange(
selectedIndices: number[],
getNameFn: (transformedIndex: number) => string | undefined,
transformFn?: (index: number) => number
): string {
if (!selectedIndices || selectedIndices.length === 0) {
return "–";
}
const weekdays = this.getWeekdaysList("short");
// Transform indices if needed (e.g., Sunday 0 -> 7 for weekdays)
const workingIndices = transformFn ? selectedIndices.map(transformFn) : selectedIndices;
// Sort the indices
const sorted = [...workingIndices].sort((a, b) => a - b);
let label = "";
const max = Math.max(...sorted);
// the week in the input-parameter starts with 0 for sunday and ends with 6 for saturday
// this algorithms works only if the week starts with 1 for monday and ends with 7 for sunday because
// then we are able to count from 1 to 7 by incrementing the number
// so we have to transform the input accordingly
const selectedWeekdaysTransformed = selectedWeekdays.map(function (dayIndex) {
return 0 === dayIndex ? 7 : dayIndex;
});
function getWeekdayName(dayIndex: number) {
return weekdays.find((day) => day.value === (7 === dayIndex ? 0 : dayIndex))?.name;
}
for (let i = 0; i < sorted.length; i++) {
const rangeStart = sorted[i];
if (rangeStart === undefined) continue;
const maxWeekday = Math.max(...selectedWeekdaysTransformed);
label += getNameFn(rangeStart);
for (let weekdayRangeStart = 1; weekdayRangeStart < 8; weekdayRangeStart++) {
if (selectedWeekdaysTransformed.includes(weekdayRangeStart)) {
label += getWeekdayName(weekdayRangeStart);
let rangeEnd = rangeStart;
let j = i;
let weekdayRangeEnd = weekdayRangeStart;
while (selectedWeekdaysTransformed.includes(weekdayRangeEnd + 1)) {
weekdayRangeEnd++;
}
// Find consecutive indices
while (j + 1 < sorted.length && sorted[j + 1] === rangeEnd + 1) {
rangeEnd++;
j++;
}
if (weekdayRangeEnd - weekdayRangeStart > 1) {
// more than 2 consecutive weekdays selected
label += " – " + getWeekdayName(weekdayRangeEnd);
weekdayRangeStart = weekdayRangeEnd;
if (maxWeekday !== weekdayRangeEnd) {
label += ", ";
}
} else if (weekdayRangeStart !== weekdayRangeEnd) {
// exactly 2 consecutive weekdays selected
label += ", ";
} else {
// exactly 1 single day selected
if (maxWeekday !== weekdayRangeEnd) {
label += ", ";
}
}
if (rangeEnd - rangeStart > 1) {
// more than 2 consecutive items selected
label += " – " + getNameFn(rangeEnd);
i = j;
} else if (rangeEnd > rangeStart) {
// 2 consecutive items selected
label += ", " + getNameFn(rangeEnd);
i = j;
}
const current = sorted[i];
if (current !== undefined && current < max) {
label += ", ";
}
}
return label;
},
fmtWeekdaysRange(selectedWeekdays: number[]): string {
const getName = (i: number) => this.fmtWeekdayByIndex(i % 7, "short");
const transform = (i: number) => i || 7;
return this.fmtConsecutiveRange(selectedWeekdays, getName, transform);
},
fmtMonthsRange(selectedMonths: number[]): string {
const getName = (i: number) => this.fmtMonthByIndex(i, "short");
return this.fmtConsecutiveRange(selectedMonths, getName);
},
// format a HH:MM to proper formatted time
fmtTimeStr(timeStr: string): string {
const [hour, minute] = timeStr.split(":").map((s) => parseInt(s, 10));
const date = new Date(2021, 0, 1);
date.setHours(hour!, minute!);
return new Intl.DateTimeFormat(this.$i18n?.locale, {
hour: "numeric",
minute: "2-digit",
hour12: is12hFormat(),
}).format(date);
},
// format a HH:MM-HH:MM to proper formatted range
fmtTimeRange(timeRange: string): string {
if (!timeRange) return "";
const parts = timeRange.split("-");
if (parts.length !== 2) return timeRange;
return `${this.fmtTimeStr(parts[0]!)} – ${this.fmtTimeStr(parts[1]!)}`;
},
},
});

View file

@ -0,0 +1,64 @@
import { defineComponent } from "vue";
import formatter from "./formatter";
const ZONE_MONTH_CODES = [
"jan",
"feb",
"mar",
"apr",
"may",
"jun",
"jul",
"aug",
"sep",
"oct",
"nov",
"dec",
];
const ZONE_DAY_CODES = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
export default defineComponent({
mixins: [formatter],
methods: {
parseWeekdaysString(daysStr: string): number[] {
const weekdays = [];
for (const part of daysStr.split(",")) {
const index = ZONE_DAY_CODES.indexOf(part);
if (index !== -1) {
weekdays.push(index);
}
}
return weekdays;
},
parseMonthsString(monthsStr: string): number[] {
const months = [];
for (const part of monthsStr.split(",")) {
const index = ZONE_MONTH_CODES.indexOf(part);
if (index !== -1) {
months.push(index);
}
}
return months;
},
formatWeekdaysToString(weekdays: number[]): string {
if (!weekdays || weekdays.length === 0) return "";
return weekdays.map((d) => ZONE_DAY_CODES[d]).join(",");
},
formatMonthsToString(months: number[]): string {
if (!months || months.length === 0) return "";
return months.map((m) => ZONE_MONTH_CODES[m]).join(",");
},
weekdaysLabel(weekdays: number[]): string {
if (!weekdays || weekdays.length === 0 || weekdays.length === 7) {
return this.$t("config.tariff.zones.allDays");
}
return this.fmtWeekdaysRange(weekdays);
},
monthsLabel(months: number[]): string {
if (!months || months.length === 0 || months.length === 12) {
return this.$t("config.tariff.zones.allMonths");
}
return this.fmtMonthsRange(months);
},
},
});

View file

@ -73,16 +73,17 @@ export interface State {
pv?: Meter[];
aux?: Meter[];
ext?: Meter[];
tariffs?: ConfigStatus<unknown, unknown>;
tariffGrid?: number;
tariffFeedIn?: number;
tariffCo2?: number;
tariffSolar?: number;
mqtt?: MqttConfig;
influx?: InfluxConfig;
hems?: Hems;
hems?: ConfigStatus<HemsConfig, HemsStatus>;
shm?: ShmConfig;
sponsor?: Sponsor;
eebus?: Eebus;
sponsor?: ConfigStatus<unknown, SponsorStatus>;
eebus?: ConfigStatus<EebusConfig, EebusStatus>;
modbusproxy?: ModbusProxy[];
messaging?: ConfigStatus<unknown, unknown>;
messagingEvents?: MessagingEvents;
@ -326,11 +327,16 @@ export enum CURRENCY {
CAD = "CAD",
CHF = "CHF",
CNY = "CNY",
CZK = "CZK",
EUR = "EUR",
GBP = "GBP",
HUF = "HUF",
ILS = "ILS",
JPY = "JPY",
NZD = "NZD",
NOK = "NOK",
PLN = "PLN",
RON = "RON",
USD = "USD",
DKK = "DKK",
SEK = "SEK",
@ -588,9 +594,10 @@ export interface SelectOption<T> {
disabled?: boolean;
}
export type DeviceType = "charger" | "meter" | "vehicle" | "loadpoint" | "messenger";
export type DeviceType = "charger" | "meter" | "vehicle" | "loadpoint" | "messenger" | "tariff";
export type MeterType = "grid" | "pv" | "battery" | "charge" | "aux" | "ext";
export type MeterTemplateUsage = "grid" | "pv" | "battery" | "charge" | "aux";
export type TariffType = "grid" | "feedIn" | "co2" | "planner" | "solar";
// see https://stackoverflow.com/a/54178819
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
@ -697,3 +704,11 @@ export interface OptimizationDetails {
export interface Error {
message: string; // Error description
}
// Tariff zone configuration
export interface Zone {
price: number | null;
days: string;
months: string;
hours: string;
}

View file

@ -0,0 +1,82 @@
import { describe, expect, test } from "vitest";
import { calculateCostRange, findRateInRange, generateRateSlots } from "./tariffSlots";
import type { Rate, Slot } from "../types/evcc";
const d = new Date();
const now = new Date();
const h = 60 * 60 * 1000;
const t1 = new Date(now.getTime() + 1 * h);
const t1h30 = new Date(now.getTime() + 1.5 * h);
const t1h45 = new Date(now.getTime() + 1.75 * h);
const t2 = new Date(now.getTime() + 2 * h);
const t3 = new Date(now.getTime() + 3 * h);
const t4 = new Date(now.getTime() + 4 * h);
describe("calculateCostRange", () => {
test("min/max", () => {
const slots: Slot[] = [
{ day: "Mon", value: 10, start: d, end: d, charging: false },
{ day: "Mon", value: 5, start: d, end: d, charging: false },
{ day: "Mon", value: 15, start: d, end: d, charging: false },
];
const { min, max } = calculateCostRange(slots);
expect(min).toBe(5);
expect(max).toBe(15);
});
test("undefined", () => {
const slots: Slot[] = [
{ day: "Mon", value: 10, start: d, end: d, charging: false },
{ day: "Mon", value: undefined, start: d, end: d, charging: false },
];
const { min, max } = calculateCostRange(slots);
expect(min).toBe(10);
expect(max).toBe(10);
});
});
describe("findRateInRange", () => {
test("overlap", () => {
const r: Rate[] = [{ start: t1, end: t2, value: 0.15 }];
expect(findRateInRange(t1h30, t1h45, r)?.value).toBe(0.15);
});
test("no match", () => {
const r: Rate[] = [{ start: t3, end: t4, value: 0.25 }];
expect(findRateInRange(t1, t1h45, r)).toBeUndefined();
});
});
describe("generateRateSlots", () => {
test("structure", () => {
const r: Rate[] = [{ start: t1, end: t2, value: 0.15 }];
const fmt = () => "Thu";
const slots = generateRateSlots(r, fmt);
expect(slots.length).toBeGreaterThan(0);
expect(slots[0]).toHaveProperty("day");
expect(slots[0]).toHaveProperty("value");
});
test("empty", () => {
const fmt = () => "Thu";
expect(generateRateSlots([], fmt)).toEqual([]);
});
test("formatter", () => {
const r: Rate[] = [{ start: t1, end: t2, value: 0.15 }];
const fmt = () => "X";
const slots = generateRateSlots(r, fmt);
expect(slots[0]?.day).toBe("X");
});
test("callbacks", () => {
const r: Rate[] = [{ start: t1, end: t2, value: 0.15 }];
const fmt = () => "Thu";
const charging = (v: number | undefined) => v !== undefined && v < 0.2;
const warning = (v: number | undefined) => v !== undefined && v > 0.2;
const slots = generateRateSlots(r, fmt, charging, warning);
const slot = slots.find((s) => s.value === 0.15);
expect(slot?.charging).toBe(true);
expect(slot?.warning).toBe(false);
});
});

View file

@ -0,0 +1,64 @@
import type { Rate, Slot } from "../types/evcc";
export function calculateCostRange(slots: Slot[]): {
min: number | undefined;
max: number | undefined;
} {
let min = undefined as number | undefined;
let max = undefined as number | undefined;
slots.forEach((slot) => {
if (slot.value === undefined) return;
min = min === undefined ? slot.value : Math.min(min, slot.value);
max = max === undefined ? slot.value : Math.max(max, slot.value);
});
return { min, max };
}
export function findRateInRange(start: Date, end: Date, rates: Rate[]): Rate | undefined {
return rates.find((r) => {
if (r.start.getTime() < start.getTime()) {
return r.end.getTime() > start.getTime();
}
return r.start.getTime() < end.getTime();
});
}
export function generateRateSlots(
rates: Rate[],
weekdayFormatter: (date: Date) => string,
isCharging?: (value: number | undefined, start: Date, end: Date) => boolean,
isWarning?: (value: number | undefined, start: Date, end: Date) => boolean
): Slot[] {
if (!rates?.length) {
return [];
}
const quarterHour = 15 * 60 * 1000;
const base = new Date();
base.setSeconds(0, 0);
base.setMinutes(base.getMinutes() - (base.getMinutes() % 15));
// Create slots for the duration of the rates
const lastRate = rates[rates.length - 1]!;
const duration = Math.ceil((lastRate.end.getTime() - base.getTime()) / quarterHour);
return Array.from({ length: Math.min(duration, 96 * 4) }, (_, i) => {
const start = new Date(base.getTime() + quarterHour * i);
const end = new Date(start.getTime() + quarterHour);
const value = findRateInRange(start, end, rates)?.value;
const charging = isCharging ? isCharging(value, start, end) : false;
const warning = isWarning ? isWarning(value, start, end) : false;
const selectable = value !== undefined;
return {
day: weekdayFormatter(start),
value,
start,
end,
charging,
selectable,
warning,
};
});
}

View file

@ -96,21 +96,6 @@
data-testid="add-grid"
@click="openModal('meter', { type: 'grid' })"
/>
<DeviceCard
:title="$t('config.tariffs.title')"
editable
:unconfigured="isUnconfigured(tariffTags)"
:error="hasClassError('tariff')"
data-testid="tariffs"
@edit="openModal('tariffs')"
>
<template #icon>
<shopicon-regular-receivepayment></shopicon-regular-receivepayment>
</template>
<template #tags>
<DeviceTags :tags="tariffTags" />
</template>
</DeviceCard>
</div>
<h2 class="my-4 mt-5">{{ $t("config.section.meter") }}</h2>
<div class="p-0 config-list">
@ -164,7 +149,87 @@
/>
</div>
<h2 class="my-4 mt-5">{{ $t("config.tariff.title") }}</h2>
<div v-if="!!tariffsYamlSource" class="p-0 config-list">
<DeviceCard
:title="$t('config.tariff.title')"
:editable="tariffsYamlSource === 'db'"
:unconfigured="isUnconfigured(tariffTags)"
:error="hasClassError('tariff')"
:badge="tariffsYamlSource === 'db'"
data-testid="tariffs-legacy"
:currency="currency"
@edit="openModal('tariffsLegacy')"
>
<template #icon>
<shopicon-regular-receivepayment></shopicon-regular-receivepayment>
</template>
<template #tags>
<DeviceTags :tags="tariffTags" :currency="currency" />
</template>
</DeviceCard>
</div>
<div v-else class="p-0 config-list">
<TariffCard
v-if="gridTariff"
:tariff="gridTariff"
tariff-type="grid"
:has-error="hasDeviceError('tariff', gridTariff.name)"
:tags="deviceTags('tariff', gridTariff.name)"
:currency="currency"
@edit="openModal('tariff', { type: 'grid', id: gridTariff.id })"
/>
<TariffCard
v-if="feedInTariff"
:tariff="feedInTariff"
tariff-type="feedIn"
:has-error="hasDeviceError('tariff', feedInTariff.name)"
:tags="deviceTags('tariff', feedInTariff.name)"
:currency="currency"
@edit="openModal('tariff', { type: 'feedIn', id: feedInTariff.id })"
/>
<NewDeviceButton
v-if="possibleTariffTypes.length"
:title="$t('config.tariff.addTariff')"
@click="openModal('tariff', { choices: possibleTariffTypes })"
/>
<TariffCard
v-if="co2Tariff"
:tariff="co2Tariff"
tariff-type="co2"
:has-error="hasDeviceError('tariff', co2Tariff.name)"
:tags="deviceTags('tariff', co2Tariff.name)"
:currency="currency"
@edit="openModal('tariff', { type: 'co2', id: co2Tariff.id })"
/>
<TariffCard
v-for="tariff in solarTariffs"
:key="tariff.name"
:tariff="tariff"
tariff-type="solar"
:has-error="hasDeviceError('tariff', tariff.name)"
:tags="deviceTags('tariff', tariff.name)"
:currency="currency"
@edit="openModal('tariff', { type: 'solar', id: tariff.id })"
/>
<TariffCard
v-if="plannerTariff"
:tariff="plannerTariff"
tariff-type="planner"
:has-error="hasDeviceError('tariff', plannerTariff.name)"
:tags="deviceTags('tariff', plannerTariff.name)"
:currency="currency"
@edit="openModal('tariff', { type: 'planner', id: plannerTariff.id })"
/>
<NewDeviceButton
v-if="possibleForecastTypes.length"
:title="$t('config.tariff.addForecast')"
@click="openModal('tariff', { choices: possibleForecastTypes })"
/>
</div>
<h2 class="my-4 mt-5">{{ $t("config.section.integrations") }}</h2>
<div class="p-0 config-list">
<AuthProvidersCard
:providers="authProviders"
@ -230,7 +295,7 @@
v-else
:key="circuit.name"
>
<hr v-if="idx > 0" />
<hr v-if="Number(idx) > 0" />
<p class="my-2 fw-bold">
{{ circuit.config?.title }}
<code>({{ circuit.name }})</code>
@ -343,7 +408,8 @@
<MessagingLegacyModal @changed="loadDirty" />
<MessagingModal :messengers="messengers" @changed="loadDirty" />
<MessengerModal @changed="messengerChanged" />
<TariffsModal @changed="loadDirty" />
<TariffsLegacyModal @changed="loadDirty" />
<TariffModal :currency="currency" @changed="tariffChanged" />
<TelemetryModal :sponsor="sponsor" :telemetry="telemetry" />
<ExperimentalModal :experimental="experimental" />
<TitleModal @changed="loadDirty" />
@ -392,6 +458,8 @@ import InfluxModal from "../components/Config/InfluxModal.vue";
import LoadpointModal from "../components/Config/LoadpointModal.vue";
import LoadpointIcon from "../components/MaterialIcon/Loadpoint.vue";
import MessagingModal from "../components/Config/Messaging/MessagingModal.vue";
import MessengerModal from "@/components/Config/Messaging/MessengerModal.vue";
import MessagingLegacyModal from "@/components/Config/Messaging/MessagingLegacyModal.vue";
import MeterModal from "../components/Config/MeterModal.vue";
import MeterCard from "../components/Config/MeterCard.vue";
import { openModal, type ModalResult } from "@/configModal";
@ -404,7 +472,9 @@ import NotificationIcon from "../components/MaterialIcon/Notification.vue";
import restart, { performRestart } from "../restart";
import SponsorModal from "../components/Config/SponsorModal.vue";
import store from "../store";
import TariffsModal from "../components/Config/TariffsModal.vue";
import TariffsLegacyModal from "../components/Config/TariffsLegacyModal.vue";
import TariffCard from "../components/Config/TariffCard.vue";
import TariffModal from "../components/Config/TariffModal.vue";
import TelemetryModal from "../components/Config/TelemetryModal.vue";
import ExperimentalModal from "../components/Config/ExperimentalModal.vue";
import TitleModal from "../components/Config/TitleModal.vue";
@ -417,16 +487,18 @@ import type {
ConfigCharger,
ConfigVehicle,
ConfigCircuit,
ConfigMessenger,
ConfigLoadpoint,
ConfigMeter,
Timeout,
VehicleOption,
MeterType,
TariffType,
SiteConfig,
DeviceType,
Notification,
ConfigMessenger,
} from "@/types/evcc";
import { CURRENCY } from "@/types/evcc";
type DeviceValuesMap = Record<DeviceType, Record<string, any>>;
@ -440,8 +512,6 @@ import WelcomeBanner from "../components/Config/WelcomeBanner.vue";
import AuthSuccessBanner from "../components/Config/AuthSuccessBanner.vue";
import PasswordModal from "../components/Auth/PasswordModal.vue";
import AuthProvidersCard from "../components/Config/AuthProvidersCard.vue";
import MessengerModal from "@/components/Config/Messaging/MessengerModal.vue";
import MessagingLegacyModal from "@/components/Config/Messaging/MessagingLegacyModal.vue";
export default defineComponent({
name: "Config",
@ -479,7 +549,9 @@ export default defineComponent({
NetworkModal,
NotificationIcon,
SponsorModal,
TariffsModal,
TariffsLegacyModal,
TariffCard,
TariffModal,
TelemetryModal,
ExperimentalModal,
TitleModal,
@ -504,6 +576,14 @@ export default defineComponent({
loadpoints: [] as ConfigLoadpoint[],
chargers: [] as ConfigCharger[],
circuits: [] as ConfigCircuit[],
tariffs: [] as any[], // ConfigTariff[] - tariff device entities
tariffRefs: {
grid: "",
feedIn: "",
co2: "",
planner: "",
solar: [] as string[],
},
site: {
grid: "",
pv: [] as string[],
@ -518,6 +598,8 @@ export default defineComponent({
vehicle: {},
charger: {},
loadpoint: {},
messenger: {},
tariff: {},
} as DeviceValuesMap,
isComponentMounted: true,
isPageVisible: true,
@ -527,9 +609,6 @@ export default defineComponent({
return { title: this.$t("config.main.title") };
},
computed: {
messagingConfigured() {
return store.state.messaging;
},
callbackCompleted() {
return this.$route.query["callbackCompleted"] as string | undefined;
},
@ -542,6 +621,9 @@ export default defineComponent({
setupRequired() {
return store.state?.setupRequired;
},
currency(): CURRENCY {
return store.state?.currency ?? CURRENCY.EUR;
},
siteTitle() {
return this.site?.title;
},
@ -565,8 +647,41 @@ export default defineComponent({
const names = this.site?.ext;
return this.getMetersByNames(names);
},
gridTariff() {
const name = this.tariffRefs?.grid;
return name ? this.tariffs.find((t) => t.name === name) : null;
},
feedInTariff() {
const name = this.tariffRefs?.feedIn;
return name ? this.tariffs.find((t) => t.name === name) : null;
},
co2Tariff() {
const name = this.tariffRefs?.co2;
return name ? this.tariffs.find((t) => t.name === name) : null;
},
plannerTariff() {
const name = this.tariffRefs?.planner;
return name ? this.tariffs.find((t) => t.name === name) : null;
},
solarTariffs() {
const names = this.tariffRefs?.solar || [];
return names.map((name) => this.tariffs.find((t) => t.name === name)).filter(Boolean);
},
possibleTariffTypes(): TariffType[] {
const types: TariffType[] = [];
if (!this.gridTariff) types.push("grid");
if (!this.feedInTariff) types.push("feedIn");
return types;
},
possibleForecastTypes(): TariffType[] {
const types: TariffType[] = [];
if (!this.co2Tariff) types.push("co2");
types.push("solar"); // Solar can have multiple
if (!this.plannerTariff) types.push("planner");
return types;
},
tariffTags(): DeviceTags {
const { currency, tariffGrid, tariffFeedIn, tariffCo2, tariffSolar } = store.state;
const { tariffGrid, tariffFeedIn, tariffCo2, tariffSolar } = store.state;
if (
tariffGrid === undefined &&
tariffFeedIn === undefined &&
@ -576,20 +691,16 @@ export default defineComponent({
return { configured: { value: false } };
}
const tags = {
currency: {},
gridPrice: {},
feedinPrice: {},
co2: {},
solarForecast: {},
};
if (currency) {
tags.currency = { value: currency };
}
if (tariffGrid) {
tags.gridPrice = { value: tariffGrid, options: { currency } };
tags.gridPrice = { value: tariffGrid };
}
if (tariffFeedIn) {
tags.feedinPrice = { value: tariffFeedIn * -1, options: { currency } };
tags.feedinPrice = { value: tariffFeedIn * -1 };
}
if (tariffCo2) {
tags.co2 = { value: tariffCo2 };
@ -669,27 +780,14 @@ export default defineComponent({
messagingTags(): DeviceTags {
if (this.messagingUiConfigured) {
const events = store.state?.messagingEvents || [];
const enabledEvents = Object.values(events).filter((e) => !e.disabled).length;
const enabledEvents = Object.values(events).filter((e: any) => !e.disabled).length;
return {
events: { value: enabledEvents },
messengers: { value: this.messengers.length },
};
}
return { configured: { value: this.messagingYamlConfigured } };
},
backupRestoreProps() {
return {
authDisabled: store.state?.authDisabled || false,
};
},
circuitsSorted() {
const sortedNames = Object.keys(store.state?.circuits || {});
return [...this.circuits].sort(
(a, b) => sortedNames.indexOf(a.name) - sortedNames.indexOf(b.name)
);
},
messagingYamlSource() {
return store.state.messaging?.yamlSource;
},
@ -702,6 +800,29 @@ export default defineComponent({
Object.values(store.state.messagingEvents ?? {}).some((e) => !e.disabled)
);
},
backupRestoreProps() {
return {
authDisabled: store.state?.authDisabled || false,
};
},
circuitsSorted() {
const sortedNames = Object.keys(store.state?.circuits || {});
return [...this.circuits].sort(
(a, b) => sortedNames.indexOf(a.name) - sortedNames.indexOf(b.name)
);
},
tariffsYamlSource() {
return store.state?.tariffs?.yamlSource;
},
tariffsUiVisible() {
return this.tariffsYamlSource === undefined;
},
tariffsYamlVisible() {
return !this.tariffsUiVisible;
},
tariffsYamlDisabled() {
return this.tariffsYamlSource === "file";
},
},
watch: {
offline() {
@ -736,13 +857,15 @@ export default defineComponent({
}
},
async loadAll() {
await this.loadMessengers();
await this.loadVehicles();
await this.loadMeters();
await this.loadSite();
await this.loadChargers();
await this.loadLoadpoints();
await this.loadCircuits();
await this.loadMessengers();
await this.loadTariffs();
await this.loadTariffRefs();
await this.loadDirty();
this.updateValues();
},
@ -780,6 +903,18 @@ export default defineComponent({
});
this.circuits = circuits;
},
async loadTariffs() {
this.tariffs = (await this.loadConfig("devices/tariff")) || [];
},
async loadTariffRefs() {
const response = await api.get("/config/tariff", {
validateStatus: (code: number) => [200, 404].includes(code),
});
if (response.status === 200) {
this.tariffRefs = response.data;
if (!this.tariffRefs.solar) this.tariffRefs.solar = [];
}
},
async loadSite() {
const data = await this.loadConfig("site");
if (data) {
@ -835,6 +970,8 @@ export default defineComponent({
if (result.action === "removed") {
await this.loadSite();
}
// Reload meters and update UI
await this.loadMeters();
await this.loadDirty();
this.updateValues();
@ -857,6 +994,25 @@ export default defineComponent({
this.loadVehicles();
this.loadDirty();
},
async tariffChanged(result: ModalResult) {
if (result.action === "added") {
const usage = result.type as TariffType;
const name = result.name!;
if (usage === "solar") {
this.tariffRefs.solar.push(name);
} else {
this.tariffRefs[usage] = name;
}
await api.put("/config/tariff", this.tariffRefs);
}
if (result.action === "removed") {
await this.loadTariffRefs();
}
await this.loadTariffs();
await this.loadTariffRefs();
await this.loadDirty();
this.updateValues();
},
openMessagingModal() {
const modalName = this.messagingYamlSource === "db" ? "messaginglegacy" : "messaging";
openModal(modalName);
@ -902,9 +1058,9 @@ export default defineComponent({
if (!this.offline) {
const devices = {
meter: this.meters,
messenger: this.messengers,
vehicle: this.vehicles,
charger: this.chargers,
tariff: this.tariffs,
} as Record<DeviceType, any[]>;
for (const type in devices) {
for (const device of devices[type as DeviceType]) {
@ -971,7 +1127,7 @@ export default defineComponent({
header?.requestAuthProvider(providerId);
},
},
});
}) as any;
</script>
<style scoped>
.config-list {

View file

@ -15,7 +15,7 @@ import (
)
var references struct {
meter, charger, vehicle, circuit []string
meter, charger, vehicle, circuit, tariff []string
}
func collectRefs(conf globalconfig.All) error {
@ -24,6 +24,11 @@ func collectRefs(conf globalconfig.All) error {
return err
}
// tariffs
if err := collectTariffRefs(); err != nil {
return err
}
// loadpoints
if err := collectLoadpointRefs(slices.Values(conf.Loadpoints)); err != nil {
return err
@ -74,6 +79,35 @@ func collectSiteRefs(conf globalconfig.All) error {
return nil
}
func collectTariffRefs() error {
// Load tariff device references from settings
if !settings.Exists(keys.TariffRefs) {
return nil
}
var refs globalconfig.TariffRefs
if err := settings.Json(keys.TariffRefs, &refs); err != nil {
return err
}
// Collect all non-empty refs
if refs.Grid != "" {
references.tariff = append(references.tariff, refs.Grid)
}
if refs.FeedIn != "" {
references.tariff = append(references.tariff, refs.FeedIn)
}
if refs.Co2 != "" {
references.tariff = append(references.tariff, refs.Co2)
}
if refs.Planner != "" {
references.tariff = append(references.tariff, refs.Planner)
}
references.tariff = append(references.tariff, refs.Solar...)
return nil
}
func collectLoadpointRefs(named iter.Seq[config.Named]) error {
for cc := range named {
var refs struct {

View file

@ -389,6 +389,9 @@ func runRoot(cmd *cobra.Command, args []string) {
Status: hemsapi.GetStatus(hems),
YamlSource: yamlSource.hems,
}}
valueChan <- util.Param{Key: keys.Tariffs, Val: globalconfig.ConfigStatus{
YamlSource: yamlSource.tariffs,
}}
// publish system infos
valueChan <- util.Param{Key: keys.Version, Val: util.FormattedVersion()}

View file

@ -85,6 +85,7 @@ var yamlSource struct {
sponsor globalconfig.YamlSource
hems globalconfig.YamlSource
eebus globalconfig.YamlSource
tariffs globalconfig.YamlSource
messaging globalconfig.YamlSource
}
@ -903,21 +904,6 @@ func tariffInstance(name string, conf config.Typed) (api.Tariff, error) {
return instance, nil
}
func configureTariff(u api.TariffUsage, conf config.Typed, t *api.Tariff) error {
if conf.Type == "" {
return nil
}
name := u.String()
res, err := tariffInstance(name, conf)
if err != nil {
return &DeviceError{name, err}
}
*t = res
return nil
}
func configureSolarTariff(conf []config.Typed, t *api.Tariff) error {
var eg errgroup.Group
tt := make([]api.Tariff, len(conf))
@ -947,41 +933,139 @@ func configureSolarTariff(conf []config.Typed, t *api.Tariff) error {
return nil
}
func configureTariffs(conf *globalconfig.Tariffs) (*tariff.Tariffs, error) {
func configureTariff(conf config.Typed, deviceName string, target *api.Tariff) error {
if conf.Type != "" {
instance, err := tariffInstance("tariff", conf)
if err != nil {
return err
}
*target = instance
return nil
}
if deviceName != "" {
dev, err := config.Tariffs().ByName(deviceName)
if err != nil {
return fmt.Errorf("tariff device not found: %w", err)
}
*target = dev.Instance()
}
return nil
}
func configureSolarTariffs(confs []config.Typed, deviceNames []string, target *api.Tariff) error {
if len(confs) > 0 {
if len(confs) == 1 {
return configureTariff(confs[0], "", target)
}
return configureSolarTariff(confs, target)
}
if len(deviceNames) > 0 {
if len(deviceNames) == 1 {
return configureTariff(config.Typed{}, deviceNames[0], target)
}
tt := make([]api.Tariff, len(deviceNames))
for i, name := range deviceNames {
dev, err := config.Tariffs().ByName(name)
if err != nil {
return fmt.Errorf("tariff device %s not found: %w", name, err)
}
tt[i] = dev.Instance()
}
*target = tariff.NewCombined(tt)
}
return nil
}
func configureTariffs(conf *globalconfig.Tariffs, names ...string) (*tariff.Tariffs, error) {
tariffs := tariff.Tariffs{
Currency: currency.EUR,
}
// migrate settings
// yaml config from file
if conf.IsConfigured() {
yamlSource.tariffs = globalconfig.YamlSourceFile
}
// yaml config from db (deprecated)
if settings.Exists(keys.Tariffs) {
if yamlSource.tariffs == globalconfig.YamlSourceFile {
// just warn, no error to not break previous behavior
log.WARN.Println("tariffs configured via UI; evcc.yaml config will be ignored")
}
*conf = globalconfig.Tariffs{}
if err := settings.Yaml(keys.Tariffs, new(map[string]any), &conf); err != nil {
return &tariffs, err
}
yamlSource.tariffs = globalconfig.YamlSourceDb
}
if conf.Currency != "" {
tariffs.Currency = currency.MustParseISO(conf.Currency)
// device config from db
var refs globalconfig.TariffRefs
if settings.Exists(keys.TariffRefs) {
if err := settings.Json(keys.TariffRefs, &refs); err != nil {
return &tariffs, err
}
if yamlSource.tariffs != globalconfig.YamlSourceNone && refs.IsConfigured() {
return &tariffs, errors.New("tariffs are configured via UI; having an additional yaml config is not allowed")
}
}
// load tariff devices from database
configurable, err := config.ConfigurationsByClass(templates.Tariff)
if err != nil {
return &tariffs, err
}
var eg errgroup.Group
eg.Go(func() error { return configureTariff(api.TariffUsageGrid, conf.Grid, &tariffs.Grid) })
eg.Go(func() error { return configureTariff(api.TariffUsageFeedIn, conf.FeedIn, &tariffs.FeedIn) })
eg.Go(func() error { return configureTariff(api.TariffUsageCo2, conf.Co2, &tariffs.Co2) })
eg.Go(func() error { return configureTariff(api.TariffUsagePlanner, conf.Planner, &tariffs.Planner) })
if len(conf.Solar) > 0 {
for _, conf := range configurable {
eg.Go(func() error {
if len(conf.Solar) == 1 {
return configureTariff(api.TariffUsageSolar, conf.Solar[0], &tariffs.Solar)
cc := conf.Named()
if len(names) > 0 && !slices.Contains(names, cc.Name) {
return nil
}
return configureSolarTariff(conf.Solar, &tariffs.Solar)
instance, err := tariffInstance(cc.Name, config.Typed{Type: cc.Type, Other: cc.Other})
if err != nil {
return err
}
if e := config.Tariffs().Add(config.NewConfigurableDevice(&conf, instance)); e != nil {
return &DeviceError{cc.Name, e}
}
return nil
})
}
if err := eg.Wait(); err != nil {
return &tariffs, err
}
// resolve tariff roles
eg = errgroup.Group{}
eg.Go(func() error { return configureTariff(conf.Grid, refs.Grid, &tariffs.Grid) })
eg.Go(func() error { return configureTariff(conf.FeedIn, refs.FeedIn, &tariffs.FeedIn) })
eg.Go(func() error { return configureTariff(conf.Co2, refs.Co2, &tariffs.Co2) })
eg.Go(func() error { return configureTariff(conf.Planner, refs.Planner, &tariffs.Planner) })
eg.Go(func() error { return configureSolarTariffs(conf.Solar, refs.Solar, &tariffs.Solar) })
if err := eg.Wait(); err != nil {
return &tariffs, &ClassError{ClassTariff, err}
}
// validate currency
if cur, _ := settings.String(keys.Currency); cur != "" {
conf.Currency = cur
}
if conf.Currency != "" {
cur, err := currency.ParseISO(conf.Currency)
if err != nil {
return &tariffs, err
}
tariffs.Currency = cur
}
return &tariffs, nil
}
@ -1060,7 +1144,7 @@ func configureSiteAndLoadpoints(conf *globalconfig.All) (*core.Site, error) {
errs = append(errs, &ClassError{ClassLoadpoint, err})
}
tariffs, err := configureTariffs(&conf.Tariffs)
tariffs, err := configureTariffs(&conf.Tariffs, references.tariff...)
if err != nil {
errs = append(errs, &ClassError{ClassTariff, err})
}

View file

@ -17,6 +17,7 @@ const (
ModbusProxy = "modbusproxy"
Ocpp = "ocpp"
Tariffs = "tariffs"
TariffRefs = "tariffRefs"
Version = "version"
Config = "config"
Database = "database"

View file

@ -105,6 +105,12 @@
"labelResidualPower": "Residualleistung",
"title": "Regelverhalten"
},
"currency": {
"description": "Wird für die Erfassung und Formatierung von Energiepreisen, Kosten und Einsparungen verwendet.",
"example": "Dein Ladepreis betrug {price}. Du hast {amount} gespart.",
"label": "Währung",
"title": "Währung"
},
"deviceValue": {
"amount": "Anzahl",
"broker": "Broker",
@ -130,11 +136,13 @@
"energy": "Energie",
"events": "Ereignisse",
"feedinPrice": "Einspeisevergütung",
"forecast": "Vorhersage",
"gridPrice": "Netzpreis",
"heaterTempLimit": "Heizungslimit",
"hemsActiveLimit": "Aktives Limit",
"hemsType": "Kommunikation",
"identifier": "RFID-Kennung",
"max": "max",
"messengers": "Dienste",
"no": "Nein",
"odometer": "Kilometerstand",
@ -696,9 +704,78 @@
"restartingDescription": "Bitte warten …",
"restartingMessage": "evcc wird neu gestartet."
},
"tariffs": {
"description": "Hinterlege deine Stromtarife, um die Kosten zu berechnen.",
"title": "Tarife"
"tariff": {
"addForecast": "Vorhersage hinzufügen",
"addTariff": "Tarif hinzufügen",
"co2": {
"description": "CO₂-Intensitätsprognose für Netzstrom. Für CO₂-optimiertes Laden und Berechnung von Emissionseinsparungen.",
"titleAdd": "CO₂-Vorhersage hinzufügen",
"titleEdit": "CO₂-Vorhersage bearbeiten"
},
"co2Services": "CO₂-Dienste",
"customForecast": "Benutzerdefinierte Vorhersage",
"customTariff": "Benutzerdefinierter Tarif",
"description": "Konfiguriere deine Energietarife und Vorhersagen. Nutze gerätebasierte Konfiguration für dynamisches Management oder YAML für statische Einstellungen.",
"feedIn": {
"description": "Vergütung für ins Netz eingespeisten Strom. Zur Berechnung der realen Ladekosten.",
"titleAdd": "Einspeisevergütung hinzufügen",
"titleEdit": "Einspeisevergütung bearbeiten"
},
"generic": "Generische Integrationen",
"grid": {
"description": "Strompreis für den Netzbezug. Für die Berechnung von realen Ladekosten und preisoptimiertes Laden von Fahrzeugen, Steuern von Wärmeerzeugern oder Netzladen der Hausbatterie.",
"titleAdd": "Netzbezugstarif hinzufügen",
"titleEdit": "Netzbezugstarif bearbeiten"
},
"legacyWarning": "Neue Tarifkonfiguration verfügbar! Entferne die Konfiguration hier und speichere, um den neuen geführten Prozess zu nutzen.",
"option": {
"co2": "CO₂-Vorhersage hinzufügen",
"feedIn": "Einspeisevergütung hinzufügen",
"grid": "Netzbezugstarif hinzufügen",
"planner": "Planer-Vorhersage hinzufügen",
"solar": "Solar-Vorhersage hinzufügen"
},
"planner": {
"description": "Erweiterte Einstellung. Normalerweise nicht nötig, da dynamische Stromtarife oder CO₂-Vorhersagen automatisch verwendet werden. Ermöglicht eine zusätzliche Datenquelle, die nur für die Ladeplanung verwendet wird und nicht für Statistiken und Preisberechnungen.",
"titleAdd": "Planer-Vorhersage hinzufügen",
"titleEdit": "Planer-Vorhersage bearbeiten"
},
"services": "Dienste",
"solar": {
"description": "Solarproduktionsprognose für deine PV-Anlage. Wird in der Oberfläche angezeigt und zukünftig für Optimierungsalgorithmen verwendet.",
"titleAdd": "Solar-Vorhersage hinzufügen",
"titleEdit": "Solar-Vorhersage bearbeiten"
},
"template": "Anbieter",
"title": "Tarife & Vorhersagen",
"titleChoice": "Was möchtest du hinzufügen?",
"type": {
"co2": "CO₂ Vorhersage",
"feedIn": "Einspeisevergütung",
"grid": "Netzbezugspreis",
"planner": "Planer",
"solar": "Solar"
},
"zones": {
"add": "Zone hinzufügen",
"allDays": "Alle Tage",
"allMonths": "Alle Monate",
"allTimes": "Alle Zeiten",
"cancel": "Abbrechen",
"days": "Tage",
"edit": "Bearbeiten",
"hours": "Stunden",
"months": "Monate",
"price": "Preis",
"priceRequired": "Preis ist erforderlich",
"remove": "Zone entfernen",
"save": "Speichern",
"selectAll": "Alle Tage",
"timeFrom": "Von",
"timeRangeError": "Startzeit muss vor Endzeit liegen. Um Mitternacht zu überspannen, erstelle zwei separate Zonen.",
"timeTo": "Bis",
"weekdays": "Wochentage"
}
},
"telemetry": {
"description": "Konfiguriere die Datenfreigabe, um evcc zu verbessern. Deine Privatsphäre ist uns wichtig und die Teilnahme ist völlig freiwillig.",

View file

@ -105,6 +105,12 @@
"labelResidualPower": "Residual power",
"title": "Control behavior"
},
"currency": {
"description": "Used to format energy prices, costs and savings based on your tariff.",
"example": "Your charging price was {price}. You saved {amount}.",
"label": "Currency",
"title": "Currency"
},
"deviceValue": {
"amount": "Amount",
"broker": "Broker",
@ -130,11 +136,13 @@
"energy": "Energy",
"events": "Events",
"feedinPrice": "Feed-in price",
"forecast": "Forecast",
"gridPrice": "Grid price",
"heaterTempLimit": "Heater limit",
"hemsActiveLimit": "Active limit",
"hemsType": "Communication",
"identifier": "RFID-Identifier",
"max": "max",
"messengers": "Services",
"no": "no",
"odometer": "Odometer",
@ -145,6 +153,7 @@
"phases1p3p": "Phase switch",
"power": "Power",
"powerRange": "Power",
"price": "Price",
"range": "Range",
"singlePhase": "Single phase",
"soc": "Charge",
@ -696,9 +705,78 @@
"restartingDescription": "Please wait…",
"restartingMessage": "Restarting evcc."
},
"tariffs": {
"description": "Define your energy tariffs to calculate the costs of your charging sessions.",
"title": "Tariffs"
"tariff": {
"addForecast": "Add forecast",
"addTariff": "Add tariff",
"co2": {
"description": "CO₂ intensity forecast for grid electricity. For CO₂-optimized charging and calculating emission savings.",
"titleAdd": "Add CO₂ Forecast",
"titleEdit": "Edit CO₂ Forecast"
},
"co2Services": "CO₂ Services",
"customForecast": "User defined forecast",
"customTariff": "User defined tariff",
"description": "Configure your energy tariffs and forecasts. Use device-based configuration for dynamic management or YAML for static settings.",
"feedIn": {
"description": "Compensation for electricity exported to the grid. Used to calculate actual charging costs.",
"titleAdd": "Add Grid Export Tariff",
"titleEdit": "Edit Grid Export Tariff"
},
"generic": "Generic integrations",
"grid": {
"description": "Electricity price for grid consumption. For calculating actual charging costs and price-optimized charging of vehicles, heating devices, or grid charging your home battery.",
"titleAdd": "Add Grid Import Tariff",
"titleEdit": "Edit Grid Import Tariff"
},
"legacyWarning": "New tariff configuration available! Remove and save your tariffs here to use the new guided process.",
"option": {
"co2": "Add CO₂ forecast",
"feedIn": "Add grid export tariff",
"grid": "Add grid import tariff",
"planner": "Add planner forecast",
"solar": "Add solar forecast"
},
"planner": {
"description": "Advanced setting. Usually not needed as dynamic electricity tariffs or CO₂ forecasts are used automatically. Enables an additional data source that's only used for charge planning, not for statistics and price calculations.",
"titleAdd": "Add Planner Forecast",
"titleEdit": "Edit Planner Forecast"
},
"services": "Services",
"solar": {
"description": "Solar production forecast for your PV system. Displayed in the interface and will be used for optimization algorithms in the future.",
"titleAdd": "Add Solar Forecast",
"titleEdit": "Edit Solar Forecast"
},
"template": "Provider",
"title": "Tariffs & Forecasts",
"titleChoice": "What Do You Want To Add?",
"type": {
"co2": "CO₂ Intensity",
"feedIn": "Feed-In Price",
"grid": "Grid Consumption Price",
"planner": "Planner",
"solar": "Solar"
},
"zones": {
"add": "Add zone",
"allDays": "All days",
"allMonths": "All months",
"allTimes": "All times",
"cancel": "Cancel",
"days": "Days",
"edit": "Edit",
"hours": "Hours",
"months": "Months",
"price": "Price",
"priceRequired": "Price is required",
"remove": "Remove zone",
"save": "Save",
"selectAll": "All days",
"timeFrom": "From",
"timeRangeError": "Start time must be before end time. To span midnight, create two separate zones.",
"timeTo": "To",
"weekdays": "Weekdays"
}
},
"telemetry": {
"description": "Configure data sharing to help improve evcc. Your privacy is important to us and participation is completely optional.",

View file

@ -12,6 +12,7 @@
"lint:i18n": "tsx i18n/check.ts",
"dev": "vite",
"playwright": "playwright test --ui",
"playwright:ci": "cross-env CI=true playwright test",
"simulator": "vite tests/simulator",
"storybook": "storybook dev -p 6006",
"license": "license-compliance --production --report detailed --allow \"MIT;Apache-2.0;ISC;BSD-2-Clause;BSD-3-Clause\""

View file

@ -339,6 +339,15 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti
api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
}
// tariffs
for _, r := range map[string]route{
"tariff": {"GET", "/tariff", tariffsHandler},
"updatetariff": {"PUT", "/tariff", updateTariffHandler},
"updatecurrency": {"PUT", "/currency", updateCurrencyHandler(pub)},
} {
api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
}
// loadpoints
for _, r := range map[string]route{
"loadpoints": {"GET", "/loadpoints", loadpointsConfigHandler()},

View file

@ -7,15 +7,20 @@ import (
"maps"
"net/http"
"reflect"
"slices"
"strconv"
"dario.cat/mergo"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/api/globalconfig"
"github.com/evcc-io/evcc/charger"
"github.com/evcc-io/evcc/core/circuit"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/messenger"
"github.com/evcc-io/evcc/meter"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/tariff"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/templates"
@ -73,6 +78,9 @@ func devicesConfigHandler(w http.ResponseWriter, r *http.Request) {
case templates.Circuit:
res, err = devicesConfig(class, config.Circuits(), hidePrivate)
case templates.Tariff:
res, err = devicesConfig(class, config.Tariffs(), hidePrivate)
case templates.Messenger:
res, err = devicesConfig(class, config.Messengers(), hidePrivate)
}
@ -196,6 +204,9 @@ func deviceConfigHandler(w http.ResponseWriter, r *http.Request) {
case templates.Circuit:
res, err = deviceConfig(class, id, config.Circuits(), hidePrivate)
case templates.Tariff:
res, err = deviceConfig(class, id, config.Tariffs(), hidePrivate)
case templates.Messenger:
res, err = deviceConfig(class, id, config.Messengers(), hidePrivate)
}
@ -255,6 +266,9 @@ func deviceStatusHandler(w http.ResponseWriter, r *http.Request) {
case templates.Circuit:
instance, err = deviceStatus(name, config.Circuits())
case templates.Tariff:
instance, err = deviceStatus(name, config.Tariffs())
case templates.Messenger:
instance, err = deviceStatus(name, config.Messengers())
}
@ -317,6 +331,9 @@ func newDeviceHandler(w http.ResponseWriter, r *http.Request) {
return circuit.NewFromConfig(ctx, util.NewLogger("circuit"), other)
}, config.Circuits(), force)
case templates.Tariff:
conf, err = newDevice(ctx, class, req, tariff.NewFromConfig, config.Tariffs(), force)
case templates.Messenger:
conf, err = newDevice(ctx, class, req, messenger.NewFromConfig, config.Messengers(), force)
}
@ -401,6 +418,9 @@ func updateDeviceHandler(w http.ResponseWriter, r *http.Request) {
return circuit.NewFromConfig(ctx, util.NewLogger("circuit"), other)
}, config.Circuits(), force)
case templates.Tariff:
err = updateDevice(ctx, id, class, req, tariff.NewFromConfig, config.Tariffs(), force)
case templates.Messenger:
err = updateDevice(ctx, id, class, req, messenger.NewFromConfig, config.Messengers(), force)
}
@ -468,6 +488,27 @@ func cleanupSiteMeterRef(name string, get func() []string, set func([]string)) {
}
}
// cleanupTariffRef removes a tariff reference from settings
func cleanupTariffRef(name string) {
if !settings.Exists(keys.TariffRefs) {
return
}
var refs globalconfig.TariffRefs
if err := settings.Json(keys.TariffRefs, &refs); err != nil {
return
}
for _, ref := range []*string{&refs.Grid, &refs.FeedIn, &refs.Co2, &refs.Planner} {
if *ref == name {
*ref = ""
}
}
refs.Solar = slices.DeleteFunc(refs.Solar, func(ref string) bool { return ref == name })
settings.SetJson(keys.TariffRefs, refs)
}
// deleteDeviceHandler deletes a device from database by class
func deleteDeviceHandler(site site.API) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
@ -550,6 +591,14 @@ func deleteDeviceHandler(site site.API) func(w http.ResponseWriter, r *http.Requ
}
}
case templates.Tariff:
err = deleteDevice(id, config.Tariffs())
// cleanup references
if err == nil {
cleanupTariffRef(config.NameForID(id))
}
case templates.Messenger:
err = deleteDevice(id, config.Messengers())
}
@ -623,6 +672,9 @@ func testConfigHandler(w http.ResponseWriter, r *http.Request) {
case templates.Circuit:
err = api.ErrNotAvailable
case templates.Tariff:
instance, err = testConfig(ctx, id, class, req, tariff.NewFromConfig, config.Tariffs())
case templates.Messenger:
instance, err = testConfig(ctx, id, class, req, messenger.NewFromConfig, config.Messengers())
}

View file

@ -351,6 +351,37 @@ func testInstance(instance any) map[string]testResult {
makeResult("identifier", val, err)
}
if dev, ok := instance.(api.Tariff); ok {
rates, err := dev.Rates()
// Determine field names based on tariff type
var valueKey, ratesKey string
switch dev.Type() {
case api.TariffTypePriceDynamic, api.TariffTypePriceForecast:
valueKey = "price"
ratesKey = "priceRates"
case api.TariffTypeCo2:
valueKey = "co2"
ratesKey = "co2Rates"
case api.TariffTypeSolar:
valueKey = "power"
ratesKey = "solarRates"
default:
valueKey = "price"
}
if err == nil && len(rates) > 0 {
// Get current rate value
if rate, err := rates.At(time.Now()); err == nil {
makeResult(valueKey, rate.Value, nil)
}
if ratesKey != "" {
makeResult(ratesKey, rates, nil)
}
}
}
return res
}

View file

@ -0,0 +1,74 @@
package server
import (
"fmt"
"net/http"
"github.com/evcc-io/evcc/api/globalconfig"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/server/db/settings"
"github.com/evcc-io/evcc/util/config"
"golang.org/x/text/currency"
)
// tariffsHandler returns assignment of tariff devices
func tariffsHandler(w http.ResponseWriter, r *http.Request) {
var refs globalconfig.TariffRefs
if settings.Exists(keys.TariffRefs) {
if err := settings.Json(keys.TariffRefs, &refs); err != nil {
jsonError(w, http.StatusInternalServerError, err)
return
}
}
jsonWrite(w, refs)
}
// updateTariffHandler updates tariff assignments
func updateTariffHandler(w http.ResponseWriter, r *http.Request) {
var refs globalconfig.TariffRefs
if err := jsonDecoder(r.Body).Decode(&refs); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
// Validate all refs
for ref := range refs.Used() {
if _, err := config.Tariffs().ByName(ref); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
}
// Save to settings
if err := settings.SetJson(keys.TariffRefs, refs); err != nil {
jsonError(w, http.StatusInternalServerError, err)
return
}
setConfigDirty()
w.WriteHeader(http.StatusAccepted)
}
// updateCurrencyHandler updates the currency setting
func updateCurrencyHandler(pub publisher) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var val string
if err := jsonDecoder(r.Body).Decode(&val); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
_, err := currency.ParseISO(val)
if err != nil {
jsonError(w, http.StatusBadRequest, fmt.Errorf("invalid currency code: %w", err))
return
}
settings.SetString(keys.Currency, val)
pub(keys.Currency, val)
w.WriteHeader(http.StatusOK)
}
}

View file

@ -11,7 +11,7 @@ requirements:
group: price
params:
- name: min
type: float
type: pricePerKWh
default: 0.10
description:
en: Minimum price
@ -20,7 +20,7 @@ params:
en: Lowest price, typically at noon (can be negative)
de: Niedrigster Preis, typisch zur Mittagszeit (kann negativ sein)
- name: max
type: float
type: pricePerKWh
default: 0.39
description:
en: Maximum price

View file

@ -1,10 +1,13 @@
template: energy-charts-api
products:
- brand: Fraunhofer ISE
- brand: Energy-Charts
description:
de: Börsenstrompreis
en: Market Price
requirements:
description:
de: "Day-ahead Energiepreise (je kWh) an der Börse. Kann ohne vorherige Anmeldung auf [api.energy-charts.info](https://api.energy-charts.info/) abgerufen werden. Nutzbar u.a. für dynamische Stromtarife, wo der Anbieter bis dato auf der Kundenschnittstelle noch kein Preis-Vorhersagen anbietet."
en: "Day-ahead forecast of energy prices (per kWh) on the exchange. No prior registration for [api.energy-charts.info](https://api.energy-charts.info/) necessary. Can be used for dynamic electricity tariffs, for example, where the supplier does not yet offer a price forecast on the customer interface."
de: "Day-ahead Energiepreise an der Börse. Bereitgestellt von Fraunhofer ISE. Kann ohne vorherige Anmeldung auf [api.energy-charts.info](https://api.energy-charts.info/) abgerufen werden. Nutzbar u.a. für dynamische Stromtarife, wo der Anbieter bis dato auf der Kundenschnittstelle noch kein Preis-Vorhersagen anbietet."
en: "Day-ahead forecast of energy prices on the exchange. Provided by Fraunhofer ISE. No prior registration for [api.energy-charts.info](https://api.energy-charts.info/) necessary. Can be used for dynamic electricity tariffs, for example, where the supplier does not yet offer a price forecast on the customer interface."
evcc: ["skiptest"]
group: price
countries: ["EU"]

View file

@ -1,8 +1,8 @@
template: ews
products:
- brand: EWS
- brand: EWS Elektrizitätswerke Schönau
description:
generic: Dynamic eco power price of EWS Elektrizitätswerke Schönau
generic: Ökostrom Dynamisch
requirements:
evcc: ["skiptest"]
description:

View file

@ -0,0 +1,38 @@
template: fixed-zones
products:
- description:
de: Zeitabhängiger Tarif
en: Time-based Tariff
requirements:
description:
de: Zeitabhängige Strompreise mit verschiedenen Preiszonen für Nachttarife, Wochenendtarife oder saisonale Tarife. Wenn sich Zonen überschneiden, gilt die zuletzt zutreffende Zone.
en: Time-dependent electricity prices with different price zones for night rates, weekend rates or seasonal tariffs. If zones overlap, the last matching zone applies.
group: price
params:
- name: price
type: pricePerKWh
required: true
description:
en: Default price
de: Standardpreis
help:
en: When no zone applies.
de: Wenn keine Zone zutrifft
- name: zones
required: true
render: |
type: fixed
price: {{ .price }}
zones:
{{- range .zones }}
- price: {{ .price }}
{{- if .days }}
days: {{ .days }}
{{- end }}
{{- if .hours }}
hours: {{ .hours }}
{{- end }}
{{- if .months }}
months: {{ .months }}
{{- end }}
{{- end }}

View file

@ -0,0 +1,16 @@
template: fixed
products:
- description:
de: Fester Preis
en: Fixed Price
group: price
params:
- name: price
type: pricePerKWh
required: true
description:
en: Price
de: Preis
render: |
type: fixed
price: {{ .price }}

View file

@ -1,8 +1,9 @@
template: octopus-de
products:
- brand: Octopus Energy Germany
- brand: Octopus Energy
description:
generic: Deutschland
de: Deutschland
en: Germany
requirements:
evcc: ["skiptest"]
countries: ["DE"]

View file

@ -193,7 +193,6 @@ energy:
source: const
value: 3000 # W`
);
await expect(restResult).toContainText("Status: unknown");
await restResult.getByRole("link", { name: "validate" }).click();
await expect(restResult).toContainText("Status: failed");
await expect(restResult).toContainText("has invalid keys: apower");
@ -208,7 +207,6 @@ energy:
source: unknown
value: 3000 # W`
);
await expect(restResult).toContainText("Status: unknown");
await restResult.getByRole("link", { name: "validate" }).click();
await expect(restResult).toContainText("Status: failed");
await expect(restResult).toContainText("invalid plugin type: unknown");
@ -223,7 +221,6 @@ energy:
source: const
value: 300 # kWh`
);
await expect(restResult).toContainText("Status: unknown");
await restResult.getByRole("link", { name: "validate" }).click();
await expect(restResult).toContainText("Status: failed");
await expect(restResult).toContainText("power: missing plugin source");

View file

@ -1,8 +1,17 @@
import { test, expect } from "@playwright/test";
import { test, expect, type Locator } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
import { expectModalHidden, expectModalVisible, editorClear, editorPaste } from "./utils";
const CONFIG_WITH_TARIFFS = "config-with-tariffs.evcc.yaml";
const CONFIG_TARIFFS_LEGACY = "tariffs-legacy.sql";
async function deleteTariff(modal: Locator, tariffLocator: Locator, nth?: number): Promise<void> {
const target = nth !== undefined ? tariffLocator.nth(nth) : tariffLocator;
await target.getByRole("button", { name: "edit" }).click();
await expectModalVisible(modal);
await modal.getByRole("button", { name: "Delete" }).click();
await expectModalHidden(modal);
}
test.use({ baseURL: baseUrl() });
test.describe.configure({ mode: "parallel" });
@ -16,22 +25,29 @@ test.describe("tariffs", async () => {
await start();
await page.goto("/#/config");
await expect(page.getByTestId("tariffs")).toBeVisible();
await expect(page.getByTestId("tariffs")).toContainText(["Configured", "no"].join(""));
// New configuration section should show with "Add Tariff" button
await expect(page.getByRole("heading", { name: "Tariffs & Forecasts" })).toBeVisible();
await expect(page.getByRole("button", { name: "Add Tariff" })).toBeVisible();
// Old tariff card should not be shown
await expect(page.getByTestId("tariffs-legacy")).not.toBeVisible();
});
test("tariffs via ui", async ({ page }) => {
await start();
test("tariffs from yaml ui (legacy)", async ({ page }) => {
await start(undefined, CONFIG_TARIFFS_LEGACY);
await page.goto("/#/config");
await page.getByTestId("tariffs").getByRole("button", { name: "edit" }).click();
const modal = await page.getByTestId("tariffs-modal");
await page.getByTestId("tariffs-legacy").getByRole("button", { name: "edit" }).click();
const modal = page.getByTestId("tariffs-legacy-modal");
await expectModalVisible(modal);
await page.waitForLoadState("networkidle");
// check for new configuration notice
await expect(modal.getByRole("alert")).toContainText("New tariff configuration available");
// default content
const editor = modal.getByTestId("yaml-editor");
await expect(editor).toContainText("#currency: EUR");
await expect(editor).toContainText("currency: EUR");
// clear and enter invalid yaml
await editorClear(editor);
@ -67,8 +83,9 @@ grid:
// restart done
await expect(restartButton).not.toBeVisible();
await expect(page.getByTestId("tariffs")).toContainText(
["Currency", "CHF", "Grid price", "12.3 ct."].join("")
await expect(page.getByTestId("generalconfig-currency")).toContainText("Currency CHF");
await expect(page.getByTestId("tariffs-legacy")).toContainText(
["Grid price", "12.3 ct."].join("")
);
});
@ -76,12 +93,11 @@ grid:
await start(CONFIG_WITH_TARIFFS);
await page.goto("/#/config");
await expect(page.getByTestId("tariffs")).toBeVisible();
await expect(page.getByTestId("tariffs")).toContainText(
await expect(page.getByTestId("generalconfig-currency")).toContainText("Currency SEK");
await expect(page.getByTestId("tariffs-legacy")).toBeVisible();
await expect(page.getByTestId("tariffs-legacy")).toContainText(
[
"Tariffs",
"Currency",
"SEK",
"Tariffs & Forecasts",
"Grid price",
"30.0 öre",
"Feed-in price",
@ -91,4 +107,234 @@ grid:
].join("")
);
});
test("create, verify, delete all types", async ({ page }) => {
await start();
await page.goto("/#/config");
const modal = page.getByTestId("tariff-modal");
const tariffGrid = page.getByTestId("tariff-grid");
const tariffFeedin = page.getByTestId("tariff-feedIn");
const tariffCo2 = page.getByTestId("tariff-co2");
const tariffSolar = page.getByTestId("tariff-solar");
const tariffPlanner = page.getByTestId("tariff-planner");
const addTariff = page.getByRole("button", { name: "Add tariff" });
const addForecast = page.getByRole("button", { name: "Add forecast" });
// modal buttons
const addGridImport = modal.getByRole("button", { name: "Add grid import tariff" });
const addGridExport = modal.getByRole("button", { name: "Add grid export tariff" });
const addCo2Forecast = modal.getByRole("button", { name: "Add CO₂ forecast" });
const addSolarForecast = modal.getByRole("button", { name: "Add solar forecast" });
const addPlannerForecast = modal.getByRole("button", { name: "Add planner forecast" });
const save = modal.getByRole("button", { name: "Validate & save" });
// initial state
await expect(addTariff).toBeVisible();
await expect(addForecast).toBeVisible();
// create grid tariff
await addTariff.click();
await expectModalVisible(modal);
await addGridImport.click();
await modal.getByLabel("Provider").selectOption("Fixed Price");
await modal.getByLabel("Price").fill("32.1");
await save.click();
await expectModalHidden(modal);
await expect(tariffGrid).toContainText(["Price", "32.1 ct"].join(""));
await expect(addTariff).toBeVisible();
// create feedin tariff
await addTariff.click();
await expectModalVisible(modal);
await addGridExport.click();
await modal.getByLabel("Provider").selectOption("Fixed Price");
await modal.getByLabel("Price").fill("8.7");
await save.click();
await expectModalHidden(modal);
await expect(tariffFeedin).toContainText(["Price", "8.7 ct"].join(""));
await expect(addTariff).not.toBeVisible();
// create CO₂ forecast
await addForecast.click();
await expectModalVisible(modal);
await addCo2Forecast.click();
await modal.getByLabel("Provider").selectOption("Demo CO₂ Forecast");
await save.click();
await expectModalHidden(modal);
await expect(tariffCo2).toBeVisible();
// create solar forecast #1
await addForecast.click();
await expectModalVisible(modal);
await addSolarForecast.click();
await modal.getByLabel("Title").fill("Roof South");
await modal.getByLabel("Provider").selectOption("Demo PV Forecast");
await save.click();
await expectModalHidden(modal);
await expect(tariffSolar.nth(0)).toContainText("Roof South");
// create solar forecast #2
await addForecast.click();
await expectModalVisible(modal);
await addSolarForecast.click();
await modal.getByLabel("Title").fill("Roof West");
await modal.getByLabel("Provider").selectOption("Demo PV Forecast");
await save.click();
await expectModalHidden(modal);
await expect(tariffSolar.nth(1)).toContainText("Roof West");
// create planner forecast
await addForecast.click();
await expectModalVisible(modal);
// co2 already exists, should not be offered
await expect(addCo2Forecast).not.toBeVisible();
await expect(addSolarForecast).toBeVisible();
await expect(addPlannerForecast).toBeVisible();
await addPlannerForecast.click();
await modal.getByLabel("Provider").selectOption("Demo Market Price");
await save.click();
await expectModalHidden(modal);
await expect(tariffPlanner).toContainText("Price");
await addForecast.click();
await expectModalVisible(modal);
await expect(addPlannerForecast).not.toBeVisible();
await modal.getByRole("button", { name: "Close" }).click();
await expectModalHidden(modal);
// restart and verify persistence
await restart();
await page.reload();
await expect(tariffGrid).toBeVisible();
await expect(tariffGrid).toContainText(["Price", "32.1 ct"].join(""));
await expect(tariffFeedin).toBeVisible();
await expect(tariffFeedin).toContainText(["Price", "8.7 ct"].join(""));
await expect(tariffCo2).toBeVisible();
await expect(tariffSolar).toHaveCount(2);
await expect(tariffSolar.nth(0)).toContainText("Roof South");
await expect(tariffSolar.nth(1)).toContainText("Roof West");
await expect(tariffPlanner).toBeVisible();
await expect(tariffPlanner).toContainText("Price");
// delete all in reverse order
await deleteTariff(modal, tariffPlanner);
await expect(tariffPlanner).toHaveCount(0);
await deleteTariff(modal, tariffSolar, 1);
await expect(tariffSolar).toHaveCount(1);
await deleteTariff(modal, tariffSolar, 0);
await expect(tariffSolar).toHaveCount(0);
await deleteTariff(modal, tariffCo2);
await expect(tariffCo2).toHaveCount(0);
await deleteTariff(modal, tariffFeedin);
await expect(tariffFeedin).toHaveCount(0);
await expect(addTariff).toBeVisible();
await deleteTariff(modal, tariffGrid);
await expect(tariffGrid).toHaveCount(0);
// final state: both add buttons visible again
await expect(addTariff).toBeVisible();
await expect(addForecast).toBeVisible();
});
test("currency change", async ({ page }) => {
await start();
await page.goto("/#/config");
const tariffModal = page.getByTestId("tariff-modal");
const tariffGrid = page.getByTestId("tariff-grid");
// create grid tariff (default EUR)
await page.getByRole("button", { name: "Add tariff" }).click();
await expectModalVisible(tariffModal);
await tariffModal.getByRole("button", { name: "Add grid import tariff" }).click();
await tariffModal.getByLabel("Provider").selectOption("Fixed Price");
await tariffModal.getByLabel("Price").fill("32.1");
await tariffModal.getByRole("button", { name: "Validate & save" }).click();
await expectModalHidden(tariffModal);
await expect(tariffGrid).toContainText(["Price", "32.1 ct"].join(""));
// change currency to NOK
await page.getByTestId("generalconfig-currency").getByRole("button", { name: "edit" }).click();
const currencyModal = page.getByTestId("currency-modal");
await expectModalVisible(currencyModal);
await currencyModal.getByLabel("Currency").selectOption("NOK");
await expect(
currencyModal.getByText("Example: Your charging price was 12.2 øre/kWh. You saved kr 20.20.")
).toBeVisible();
await currencyModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(currencyModal);
// verify
await expect(tariffGrid).toContainText(["Price", "32.1 øre"].join(""));
});
test("time-based tariff (zones)", async ({ page }) => {
await start();
await page.goto("/#/config");
const modal = page.getByTestId("tariff-modal");
const tariffGrid = page.getByTestId("tariff-grid");
const addTariff = page.getByRole("button", { name: "Add tariff" });
const addGridImport = modal.getByRole("button", { name: "Add grid import tariff" });
const save = modal.getByRole("button", { name: "Validate & save" });
// select time-based tariff template
await addTariff.click();
await expectModalVisible(modal);
await addGridImport.click();
await modal.getByLabel("Provider").selectOption("Time-based Tariff");
await modal.getByLabel("Default price").fill("20");
const addZone = modal.getByRole("button", { name: "Add zone" });
await expect(addZone).toBeVisible();
// create intermediate zone with complex constraints
await addZone.click();
await modal.getByLabel("Price", { exact: true }).fill("0");
await modal.getByRole("button", { name: "Months" }).click();
await modal.getByLabel("Oct").check();
await modal.getByLabel("Nov").check();
await modal.getByLabel("Dec").check();
await modal.getByRole("button", { name: "Months" }).click();
await modal.getByRole("button", { name: "Weekdays" }).click();
await modal.getByLabel("Sun").check();
await modal.getByRole("button", { name: "Weekdays" }).click();
await modal.getByLabel("From", { exact: true }).fill("01:00");
await modal.getByLabel("To", { exact: true }).fill("03:00");
await modal.getByRole("button", { name: "Save", exact: true }).click();
// verify and delete
const zones = modal.getByTestId("property-zone");
await expect(zones).toHaveCount(1);
await expect(zones.first()).toContainText(["0.0 ct", "Sun, Oct – Dec, 01:00 – 03:00"].join(""));
await zones.first().getByRole("button", { name: "Remove zone" }).click();
await expect(zones).toHaveCount(0);
// create night rate zone
await addZone.click();
await modal.getByLabel("Price", { exact: true }).fill("10");
await modal.getByLabel("From", { exact: true }).fill("00:00");
await modal.getByLabel("To", { exact: true }).fill("06:00");
await modal.getByRole("button", { name: "Save", exact: true }).click();
await expect(zones.first()).toContainText(["10.0 ct", "00:00 – 06:00"].join(""));
// create peak rate zone
await addZone.click();
await modal.getByLabel("Price", { exact: true }).fill("30");
await modal.getByLabel("From", { exact: true }).fill("16:00");
await modal.getByLabel("To", { exact: true }).fill("20:00");
await modal.getByRole("button", { name: "Save", exact: true }).click();
// verify zones
await expect(zones).toHaveCount(2);
await expect(zones.nth(0)).toContainText(["10.0 ct", "00:00 – 06:00"].join(""));
await expect(zones.nth(1)).toContainText(["30.0 ct", "16:00 – 20:00"].join(""));
// save and verify
await save.click();
await expectModalHidden(modal);
await expect(tariffGrid).toBeVisible();
await expect(tariffGrid).toContainText(["Forecast", "10.0 ct – 30.0 ct"].join(""));
});
});

View file

@ -27,3 +27,8 @@ tariffs:
grid:
type: fixed
price: 0.4 # EUR/kWh
co2:
type: template
template: grünstromindex
zip: 123
token: 123123

8
tests/tariffs-legacy.sql Normal file
View file

@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS `settings` (
`key` text
, `value` text
, PRIMARY KEY(`key`)
);
-- legacy tariff configuration stored in database
INSERT INTO settings("key", value) VALUES('tariffs', 'currency: EUR');

View file

@ -15,6 +15,7 @@ var instance struct {
circuits *handler[api.Circuit]
messengers *handler[api.Messenger]
loadpoints *handler[loadpoint.API]
tariffs *handler[api.Tariff]
}
func init() {
@ -28,6 +29,7 @@ func Reset() {
instance.circuits = &handler[api.Circuit]{topic: "circuit"}
instance.messengers = &handler[api.Messenger]{topic: "messenger"}
instance.loadpoints = &handler[loadpoint.API]{topic: "loadpoint"}
instance.tariffs = &handler[api.Tariff]{topic: "tariff"}
}
type Handler[T any] interface {
@ -62,6 +64,10 @@ func Loadpoints() Handler[loadpoint.API] {
return instance.loadpoints
}
func Tariffs() Handler[api.Tariff] {
return instance.tariffs
}
// Instances returns the instances of the given devices
func Instances[T any](devices []Device[T]) []T {
res := make([]T, 0, len(devices))

View file

@ -470,6 +470,11 @@ params:
type: float
example: 9.1275
service: location/lon
- name: zones
type: zones
description:
en: Price zones
de: Preiszonen
presets:
vehicle-base:
@ -531,14 +536,14 @@ presets:
- name: language
tariff-base:
- name: charges
type: float
type: pricePerKWh
advanced: true
description:
en: Charge
de: Aufschlag
help:
de: Zusätzlicher fester Aufschlag pro kWh (z.B. 0.05 für 5 Cent)
en: Additional fixed charge per kWh (e.g. 0.05 for 5 cents)
de: Zusätzlicher fester Aufschlag pro kWh
en: Additional fixed charge per kWh
- name: tax
type: float
advanced: true

View file

@ -12,4 +12,6 @@ const (
TypeFloat
TypeInt
TypeList
TypeZones
TypePricePerKWh
)

View file

@ -7,11 +7,11 @@ import (
"strings"
)
const _ParamTypeName = "StringBoolChoiceChargeModesDurationFloatIntList"
const _ParamTypeName = "StringBoolChoiceChargeModesDurationFloatIntListZonesPricePerKWh"
var _ParamTypeIndex = [...]uint8{0, 6, 10, 16, 27, 35, 40, 43, 47}
var _ParamTypeIndex = [...]uint8{0, 6, 10, 16, 27, 35, 40, 43, 47, 52, 63}
const _ParamTypeLowerName = "stringboolchoicechargemodesdurationfloatintlist"
const _ParamTypeLowerName = "stringboolchoicechargemodesdurationfloatintlistzonespriceperkwh"
func (i ParamType) String() string {
if i < 0 || i >= ParamType(len(_ParamTypeIndex)-1) {
@ -32,9 +32,11 @@ func _ParamTypeNoOp() {
_ = x[TypeFloat-(5)]
_ = x[TypeInt-(6)]
_ = x[TypeList-(7)]
_ = x[TypeZones-(8)]
_ = x[TypePricePerKWh-(9)]
}
var _ParamTypeValues = []ParamType{TypeString, TypeBool, TypeChoice, TypeChargeModes, TypeDuration, TypeFloat, TypeInt, TypeList}
var _ParamTypeValues = []ParamType{TypeString, TypeBool, TypeChoice, TypeChargeModes, TypeDuration, TypeFloat, TypeInt, TypeList, TypeZones, TypePricePerKWh}
var _ParamTypeNameToValueMap = map[string]ParamType{
_ParamTypeName[0:6]: TypeString,
@ -53,6 +55,10 @@ var _ParamTypeNameToValueMap = map[string]ParamType{
_ParamTypeLowerName[40:43]: TypeInt,
_ParamTypeName[43:47]: TypeList,
_ParamTypeLowerName[43:47]: TypeList,
_ParamTypeName[47:52]: TypeZones,
_ParamTypeLowerName[47:52]: TypeZones,
_ParamTypeName[52:63]: TypePricePerKWh,
_ParamTypeLowerName[52:63]: TypePricePerKWh,
}
var _ParamTypeNames = []string{
@ -64,6 +70,8 @@ var _ParamTypeNames = []string{
_ParamTypeName[35:40],
_ParamTypeName[40:43],
_ParamTypeName[43:47],
_ParamTypeName[47:52],
_ParamTypeName[52:63],
}
// ParamTypeString retrieves an enum value from the enum constants string name.

View file

@ -361,12 +361,17 @@ func (t *Template) RenderResult(renderMode int, other map[string]any) ([]byte, m
switch typed := val.(type) {
case []any:
var list []string
for _, v := range typed {
list = append(list, p.yamlQuote(fmt.Sprintf("%v", v)))
}
if res[out] == nil || len(res[out].([]any)) == 0 {
res[out] = list
if i != -1 && p.Type == TypeZones {
// keep as structured data
res[out] = typed
} else {
var list []string
for _, v := range typed {
list = append(list, p.yamlQuote(fmt.Sprintf("%v", v)))
}
if res[out] == nil || len(res[out].([]any)) == 0 {
res[out] = list
}
}
case []string:

View file

@ -235,6 +235,9 @@ func (p *Param) DefaultValue(renderMode int) any {
if p.Type == TypeList {
return []string{}
}
if p.Type == TypeZones {
return []any{}
}
if (renderMode == RenderModeDocs || renderMode == RenderModeUnitTest) && p.Default == "" {
return p.Example