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:
parent
ba159992fd
commit
a4ed1080e1
55 changed files with 2711 additions and 310 deletions
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
121
assets/js/components/Config/CurrencyModal.vue
Normal file
121
assets/js/components/Config/CurrencyModal.vue
Normal 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>
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
},
|
||||
|
|
|
|||
232
assets/js/components/Config/PropertyZoneForm.vue
Normal file
232
assets/js/components/Config/PropertyZoneForm.vue
Normal 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>
|
||||
69
assets/js/components/Config/PropertyZoneSummary.vue
Normal file
69
assets/js/components/Config/PropertyZoneSummary.vue
Normal 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>
|
||||
110
assets/js/components/Config/PropertyZonesField.vue
Normal file
110
assets/js/components/Config/PropertyZonesField.vue
Normal 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>
|
||||
76
assets/js/components/Config/TariffCard.vue
Normal file
76
assets/js/components/Config/TariffCard.vue
Normal 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>
|
||||
255
assets/js/components/Config/TariffModal.vue
Normal file
255
assets/js/components/Config/TariffModal.vue
Normal 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>
|
||||
|
|
@ -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() {
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
28
assets/js/components/Config/defaultYaml/tariffCo2.yaml
Normal file
28
assets/js/components/Config/defaultYaml/tariffCo2.yaml
Normal 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
|
||||
25
assets/js/components/Config/defaultYaml/tariffPrice.yaml
Normal file
25
assets/js/components/Config/defaultYaml/tariffPrice.yaml
Normal 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
|
||||
28
assets/js/components/Config/defaultYaml/tariffSolar.yaml
Normal file
28
assets/js/components/Config/defaultYaml/tariffSolar.yaml
Normal 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
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]!)}`;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
64
assets/js/mixins/zoneUtils.ts
Normal file
64
assets/js/mixins/zoneUtils.ts
Normal 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);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
82
assets/js/utils/tariffSlots.test.ts
Normal file
82
assets/js/utils/tariffSlots.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
64
assets/js/utils/tariffSlots.ts
Normal file
64
assets/js/utils/tariffSlots.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
36
cmd/refs.go
36
cmd/refs.go
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
140
cmd/setup.go
140
cmd/setup.go
|
|
@ -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})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const (
|
|||
ModbusProxy = "modbusproxy"
|
||||
Ocpp = "ocpp"
|
||||
Tariffs = "tariffs"
|
||||
TariffRefs = "tariffRefs"
|
||||
Version = "version"
|
||||
Config = "config"
|
||||
Database = "database"
|
||||
|
|
|
|||
83
i18n/de.json
83
i18n/de.json
|
|
@ -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.",
|
||||
|
|
|
|||
84
i18n/en.json
84
i18n/en.json
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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\""
|
||||
|
|
|
|||
|
|
@ -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()},
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
74
server/http_config_tariff_handler.go
Normal file
74
server/http_config_tariff_handler.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
38
templates/definition/tariff/fixed-zones.yaml
Normal file
38
templates/definition/tariff/fixed-zones.yaml
Normal 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 }}
|
||||
16
templates/definition/tariff/fixed.yaml
Normal file
16
templates/definition/tariff/fixed.yaml
Normal 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 }}
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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(""));
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
8
tests/tariffs-legacy.sql
Normal 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');
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -12,4 +12,6 @@ const (
|
|||
TypeFloat
|
||||
TypeInt
|
||||
TypeList
|
||||
TypeZones
|
||||
TypePricePerKWh
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue