Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions assets/js/components/Config/DeviceTags.vue
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,17 @@ 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);
case "co2":
return this.fmtCo2Short(value);
case "forecastUntil":
return this.fmtDurationLong(
(new Date(value).getTime() - Date.now()) / 1000,
"short"
);
case "powerRange":
return `${this.fmtW(value[0])} / ${this.fmtW(value[1])}`;
case "currentRange":
Expand Down
76 changes: 76 additions & 0 deletions assets/js/components/Config/TariffCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<template>
<DeviceCard
:title="cardTitle"
:name="tariff.name"
:editable="!!tariff.id"
:error="hasError"
:data-testid="tariffType"
@edit="$emit('edit', tariffType, tariff.id)"
>
<template #icon>
<component :is="iconComponent" />
</template>
<template #tags>
<DeviceTags :tags="tags" />
</template>
</DeviceCard>
</template>

<script lang="ts">
import "@h2d2/shopicons/es/regular/invoice";
import "@h2d2/shopicons/es/regular/eco1";
import "@h2d2/shopicons/es/regular/clock";
import "@h2d2/shopicons/es/regular/sun";
import { defineComponent, type PropType } from "vue";
import DeviceCard from "./DeviceCard.vue";
import DeviceTags from "./DeviceTags.vue";
import type { TariffType } from "@/types/evcc";

type ConfigTariff = {
id: number;
name: string;
config?: {
template?: string;
};
};

export default defineComponent({
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: () => ({}) },
},
emits: ["edit"],
computed: {
cardTitle(): string {
if (this.title) {
return this.title;
}
if (this.tariff.config?.template) {
return this.tariff.config.template;
}
return this.fallbackTitle;
},
fallbackTitle(): string {
return this.$t(`config.tariff.type.${this.tariffType}`);
},
iconComponent(): string {
const iconMap: Record<TariffType, string> = {
grid: "shopicon-regular-invoice",
feedin: "shopicon-regular-invoice",
co2: "shopicon-regular-eco1",
planner: "shopicon-regular-clock",
solar: "shopicon-regular-sun",
};
return iconMap[this.tariffType];
},
},
});
</script>
197 changes: 197 additions & 0 deletions assets/js/components/Config/TariffModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
<template>
<DeviceModalBase
:id="id"
modal-id="tariffModal"
device-type="tariff"
:modal-title="modalTitle"
:provide-template-options="provideTemplateOptions"
:initial-values="initialValues"
:show-main-content="!!tariffType"
:on-template-change="handleTemplateChange"
@added="handleAdded"
@updated="$emit('updated')"
@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>
</DeviceModalBase>
</template>

<script lang="ts">
import { defineComponent, type PropType } from "vue";
import DeviceModalBase from "./DeviceModal/DeviceModalBase.vue";
import NewDeviceButton from "./NewDeviceButton.vue";
import { ConfigType, type TariffType } from "@/types/evcc";
import { customTemplateOption, type TemplateGroup } from "./DeviceModal/TemplateSelector.vue";
import type { Product, DeviceValues } from "./DeviceModal";
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,
deviceProduct: undefined,
yaml: undefined,
template: null,
};

export default defineComponent({
name: "TariffModal",
components: {
DeviceModalBase,
NewDeviceButton,
},
props: {
id: Number,
type: { type: String as PropType<TariffType>, default: null },
typeChoices: { type: Array as () => TariffType[], default: () => [] },
},
emits: ["added", "updated", "removed", "close"],
data() {
return {
initialValues,
selectedType: null as TariffType | null,
};
},
computed: {
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`);
},
},
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 = [
"demo-co2-forecast",
"demo-dynamic-grid",
"demo-solar-forecast",
"energy-charts-api",
];

// Filter products by group upfront
const filterByGroup = (group: string, onlyGeneric: boolean = false) =>
products.filter((p: Product) => {
const isGeneric = genericTemplates.includes(p.template);
return p.group === group && (onlyGeneric ? isGeneric : !isGeneric);
});

const priceProducts = filterByGroup("price");
const co2Products = filterByGroup("co2");
const solarProducts = filterByGroup("solar");
const priceGeneric = filterByGroup("price", true);
const co2Generic = filterByGroup("co2", true);
const solarGeneric = filterByGroup("solar", true);

// 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("added", this.tariffType, name);
},
handleRemoved() {
this.$emit("removed", this.tariffType);
},
handleClose() {
this.selectedType = null;
this.$emit("close");
},
},
});
</script>
<style scoped>
.addButton {
min-height: 6rem;
}
</style>
4 changes: 2 additions & 2 deletions assets/js/components/Config/TariffsModal.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<template>
<YamlModal
id="tariffsModal"
:title="$t('config.tariffs.title')"
:description="$t('config.tariffs.description')"
:title="$t('config.tariff.title')"
:description="$t('config.tariff.description')"
docs="/docs/tariffs"
:defaultYaml="defaultYaml"
removeKey="tariffs"
Expand Down
28 changes: 28 additions & 0 deletions assets/js/components/Config/defaultYaml/tariffCo2.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
## COβ‚‚ intensity forecast

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 changes: 25 additions & 0 deletions assets/js/components/Config/defaultYaml/tariffPrice.yaml
Original file line number Diff line number Diff line change
@@ -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 changes: 28 additions & 0 deletions assets/js/components/Config/defaultYaml/tariffSolar.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
## PV production forecast

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
3 changes: 2 additions & 1 deletion assets/js/types/evcc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -529,9 +529,10 @@ export interface SelectOption<T> {
disabled?: boolean;
}

export type DeviceType = "charger" | "meter" | "vehicle" | "loadpoint";
export type DeviceType = "charger" | "meter" | "vehicle" | "loadpoint" | "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>>;
Expand Down
Loading
Loading