HEMS: add templates (#25832)
Co-authored-by: Michael Geers <michael@geers.tv> Co-authored-by: premultiply <4681172+premultiply@users.noreply.github.com>
This commit is contained in:
parent
37e96867ab
commit
06f6eb1d26
35 changed files with 985 additions and 175 deletions
|
|
@ -93,6 +93,7 @@ Deep documentation on specific subsystems is available in `docs/agents/`. Load w
|
|||
- In user-facing strings, only mention `evcc` when needed to understand the context. Inside evcc's own UI the self-reference is usually redundant
|
||||
- Acronyms uppercase in prose: OCPP, MQTT, HEMS, SoC
|
||||
- Terminology: German "Phasensaldierung" (meter netting signed power across phases each instant) is "summative energy measurement" in English. Avoid "phase balancing" (means load balancing) and "net metering" (a billing scheme)
|
||||
- Terminology: the top-level load management circuit is "root circuit" in English, "Hauptstromkreis" in German
|
||||
- Commit subjects: `Component: short description`, no trailing period. Sub-scope in parens: `Meter (Home Assistant): ...`. Use `chore:`/`fix:`/`docs:` only for non-feature changes
|
||||
|
||||
## Comment Style
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
@visibilitychange="handleVisibilityChange"
|
||||
>
|
||||
<template #header-actions>
|
||||
<DeviceInfoButton v-if="id" :id="id" />
|
||||
<DeviceInfoButton v-if="id && !hideInfo" :id="id" />
|
||||
</template>
|
||||
<form ref="form" class="container mx-0 px-0">
|
||||
<slot name="pre-content" :values="values"></slot>
|
||||
|
|
@ -30,7 +30,11 @@
|
|||
:product-name="productName"
|
||||
:groups="computedTemplateOptions"
|
||||
@change="handleTemplateChange"
|
||||
/>
|
||||
>
|
||||
<template v-if="$slots['template-action']" #action>
|
||||
<slot name="template-action" />
|
||||
</template>
|
||||
</TemplateSelector>
|
||||
|
||||
<p v-if="showDeprecatedWarning" class="text-danger">
|
||||
{{ $t("config.general.typeDeprecated", { type: values.type }) }}
|
||||
|
|
@ -299,11 +303,18 @@ export default defineComponent({
|
|||
externalTemplate: String as PropType<string | null>,
|
||||
// Optional: hide template fields, e.g. because ocpp step was not completed
|
||||
hideTemplateFields: { type: Boolean, default: false },
|
||||
// Optional: keep modal open after a remove (singletons want to re-enter create mode)
|
||||
keepOpenOnRemove: { type: Boolean, default: false },
|
||||
// Optional: hide the bottom-right delete button (e.g. when delete is offered inline)
|
||||
hideDelete: { type: Boolean, default: false },
|
||||
// Optional: hide the info button in the header (e.g. for singleton devices like hems)
|
||||
hideInfo: { type: Boolean, default: false },
|
||||
},
|
||||
emits: [
|
||||
"added",
|
||||
"updated",
|
||||
"removed",
|
||||
"open",
|
||||
"close",
|
||||
"template-changed",
|
||||
"update:externalTemplate",
|
||||
|
|
@ -435,7 +446,7 @@ export default defineComponent({
|
|||
return this.id === undefined;
|
||||
},
|
||||
isDeletable() {
|
||||
return !this.isNew;
|
||||
return !this.isNew && !this.hideDelete;
|
||||
},
|
||||
showActions() {
|
||||
// explicitly hide template fields (ocpp step 1)
|
||||
|
|
@ -501,6 +512,20 @@ export default defineComponent({
|
|||
}
|
||||
}
|
||||
},
|
||||
id(newVal, oldVal) {
|
||||
if (!this.isModalVisible) return;
|
||||
// id arrived after open (async lookup) → load existing device
|
||||
if (newVal !== undefined && oldVal === undefined) {
|
||||
this.loadConfiguration();
|
||||
return;
|
||||
}
|
||||
// id removed while modal stays open → reset to create mode
|
||||
if (newVal === undefined && oldVal !== undefined) {
|
||||
this.reset();
|
||||
this.templateName = null;
|
||||
this.succeeded = false;
|
||||
}
|
||||
},
|
||||
templateName(newValue, oldValue) {
|
||||
// Sync back to parent if using externalTemplate
|
||||
if (this.externalTemplate !== undefined && newValue !== this.externalTemplate) {
|
||||
|
|
@ -786,6 +811,9 @@ export default defineComponent({
|
|||
try {
|
||||
await this.device.remove(this.id!);
|
||||
this.$emit("removed");
|
||||
if (this.keepOpenOnRemove) {
|
||||
return;
|
||||
}
|
||||
await closeModal({ action: "removed" });
|
||||
} catch (e) {
|
||||
handleError(e, "remove failed");
|
||||
|
|
@ -793,6 +821,7 @@ export default defineComponent({
|
|||
},
|
||||
handleOpen() {
|
||||
this.isModalVisible = true;
|
||||
this.$emit("open");
|
||||
this.adminPasswordRequired = false;
|
||||
this.adminPasswordInvalid = false;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -24,14 +24,16 @@
|
|||
</optgroup>
|
||||
</template>
|
||||
</select>
|
||||
<input
|
||||
v-else
|
||||
:id="`${deviceType}Template`"
|
||||
type="text"
|
||||
:value="productName || $t('config.general.customOption')"
|
||||
disabled
|
||||
class="form-control w-100"
|
||||
/>
|
||||
<div v-else class="d-flex gap-2 align-items-stretch">
|
||||
<input
|
||||
:id="`${deviceType}Template`"
|
||||
type="text"
|
||||
:value="productName || $t('config.general.customOption')"
|
||||
disabled
|
||||
class="form-control"
|
||||
/>
|
||||
<slot name="action" />
|
||||
</div>
|
||||
</FormRow>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +1,55 @@
|
|||
<template>
|
||||
<YamlModal
|
||||
<DeviceModalBase
|
||||
:id="id"
|
||||
ref="deviceModal"
|
||||
name="hems"
|
||||
:title="$t('config.hems.title')"
|
||||
:description="$t('config.hems.description')"
|
||||
docs="/docs/features/external-control"
|
||||
:defaultYaml="defaultYaml"
|
||||
endpoint="/config/hems"
|
||||
removeKey="hems"
|
||||
:noYamlEditor="fromYaml"
|
||||
:disableSave="fromYaml"
|
||||
@changed="$emit('changed')"
|
||||
device-type="hems"
|
||||
:modal-title="$t('config.hems.title')"
|
||||
:provide-template-options="provideTemplateOptions"
|
||||
:initial-values="initialValues"
|
||||
:is-yaml-input-type="isYamlInputType"
|
||||
:on-template-change="handleTemplateChange"
|
||||
:hide-template-fields="fromYaml"
|
||||
:hide-delete="true"
|
||||
:hide-info="true"
|
||||
:keep-open-on-remove="true"
|
||||
@added="onAdded"
|
||||
@updated="onUpdated"
|
||||
@removed="onRemoved"
|
||||
@open="loadSessions"
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<template #afterDescription>
|
||||
<template v-if="id !== undefined && !fromYaml" #template-action>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary border-0"
|
||||
:aria-label="$t('config.general.change')"
|
||||
:title="$t('config.general.change')"
|
||||
:disabled="changing"
|
||||
@click="handleChange"
|
||||
>
|
||||
<shopicon-regular-edit size="s" class="flex-shrink-0"></shopicon-regular-edit>
|
||||
</button>
|
||||
</template>
|
||||
<template #description>
|
||||
<p class="mt-0 mb-4">
|
||||
{{ $t("config.hems.description") }}
|
||||
<a :href="docsLink" target="_blank" rel="noopener">
|
||||
{{ $t("config.general.docsLink") }}
|
||||
</a>
|
||||
</p>
|
||||
<div
|
||||
v-if="sessionCount"
|
||||
class="alert alert-info my-4 d-flex justify-content-between align-items-start flex-wrap gap-2"
|
||||
role="alert"
|
||||
data-testid="grid-sessions"
|
||||
>
|
||||
<div>
|
||||
<span>{{ $t("config.hems.eventsRecorded", { count: sessionCount }) }}</span>
|
||||
<span class="ms-2">{{
|
||||
$t("config.hems.lastEvent", { timeAgo: formatLastEvent(lastEvent.created) })
|
||||
<div class="d-flex flex-wrap">
|
||||
<span class="me-2">{{
|
||||
$t("config.hems.eventsRecorded", { count: sessionCount })
|
||||
}}</span>
|
||||
<span v-if="lastEventTimeAgo">{{
|
||||
$t("config.hems.lastEvent", { timeAgo: lastEventTimeAgo })
|
||||
}}</span>
|
||||
</div>
|
||||
<a
|
||||
|
|
@ -38,71 +65,131 @@
|
|||
{{ $t("config.general.fromYamlHint") }}
|
||||
</p>
|
||||
</template>
|
||||
</YamlModal>
|
||||
</DeviceModalBase>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import YamlModal from "./YamlModal.vue";
|
||||
import defaultYaml from "./defaultYaml/hems.yaml?raw";
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import "@h2d2/shopicons/es/regular/edit";
|
||||
import DeviceModalBase from "./DeviceModal/DeviceModalBase.vue";
|
||||
import { ConfigType, type YamlSource } from "@/types/evcc";
|
||||
import { type DeviceValues } from "./DeviceModal";
|
||||
import { customTemplateOption, type TemplateGroup } from "./DeviceModal/TemplateSelector.vue";
|
||||
import customHemsYaml from "./defaultYaml/customHems.yaml?raw";
|
||||
import api from "../../api";
|
||||
import { docsPrefix } from "@/i18n";
|
||||
import { handleDownloadClick } from "../../utils/native";
|
||||
import formatter from "../../mixins/formatter";
|
||||
|
||||
export default {
|
||||
const initialValues = {
|
||||
type: ConfigType.Template,
|
||||
icon: undefined,
|
||||
deviceProduct: undefined,
|
||||
yaml: undefined,
|
||||
template: null,
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: "HemsModal",
|
||||
components: { YamlModal },
|
||||
components: { DeviceModalBase },
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
yamlSource: String,
|
||||
yamlSource: String as PropType<YamlSource>,
|
||||
id: Number as PropType<number | undefined>,
|
||||
},
|
||||
emits: ["changed"],
|
||||
emits: ["changed", "close"],
|
||||
data() {
|
||||
return {
|
||||
defaultYaml: defaultYaml.trim(),
|
||||
sessions: [],
|
||||
initialValues,
|
||||
sessions: [] as Array<{ created: string }>,
|
||||
changing: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
fromYaml() {
|
||||
fromYaml(): boolean {
|
||||
return this.yamlSource === "file";
|
||||
},
|
||||
sessionCount() {
|
||||
sessionCount(): number {
|
||||
return this.sessions.length;
|
||||
},
|
||||
lastEvent() {
|
||||
if (!this.sessions.length) {
|
||||
return null;
|
||||
}
|
||||
return this.sessions[0];
|
||||
return this.sessions[0] ?? null;
|
||||
},
|
||||
csvLink() {
|
||||
lastEventTimeAgo(): string {
|
||||
const created = this.lastEvent?.created;
|
||||
if (!created) return "";
|
||||
const ms = new Date(created).getTime();
|
||||
if (!Number.isFinite(ms)) return "";
|
||||
return (this as any).fmtTimeAgo(ms - Date.now());
|
||||
},
|
||||
csvLink(): string {
|
||||
const params = new URLSearchParams({
|
||||
format: "csv",
|
||||
lang: this.$i18n?.locale,
|
||||
});
|
||||
return `./api/gridsessions?${params.toString()}`;
|
||||
},
|
||||
docsLink(): string {
|
||||
return `${docsPrefix()}/docs/features/external-control`;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleDownloadClick,
|
||||
async loadSessions() {
|
||||
try {
|
||||
const response = await api.get("gridsessions", {
|
||||
validateStatus: (code) => [200, 404].includes(code),
|
||||
validateStatus: (code: number) => [200, 404].includes(code),
|
||||
});
|
||||
this.sessions = response.data || [];
|
||||
} catch (e) {
|
||||
// Silently fail if no sessions available
|
||||
this.sessions = [];
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
formatLastEvent(created) {
|
||||
const now = new Date();
|
||||
const eventDate = new Date(created);
|
||||
const diffMs = now - eventDate;
|
||||
return this.fmtTimeAgo(-diffMs);
|
||||
provideTemplateOptions(products: any[]): TemplateGroup[] {
|
||||
if (this.fromYaml) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
label: "generic",
|
||||
options: [customTemplateOption(this.$t("config.hems.customOption"))],
|
||||
},
|
||||
{
|
||||
label: "integrations",
|
||||
options: products,
|
||||
},
|
||||
];
|
||||
},
|
||||
isYamlInputType(type: ConfigType): boolean {
|
||||
return type === ConfigType.Custom;
|
||||
},
|
||||
handleTemplateChange(e: Event, values: DeviceValues) {
|
||||
const value = (e.target as HTMLSelectElement).value as ConfigType;
|
||||
if (value === ConfigType.Custom) {
|
||||
values.type = ConfigType.Custom;
|
||||
values.yaml = customHemsYaml;
|
||||
}
|
||||
},
|
||||
onAdded(name: string) {
|
||||
this.$emit("changed", { action: "added", name });
|
||||
},
|
||||
onUpdated() {
|
||||
this.$emit("changed", { action: "updated" });
|
||||
},
|
||||
onRemoved() {
|
||||
this.$emit("changed", { action: "removed" });
|
||||
},
|
||||
async handleChange() {
|
||||
if (this.id === undefined) return;
|
||||
if (!window.confirm(this.$t("config.hems.changeConfirm"))) return;
|
||||
this.changing = true;
|
||||
try {
|
||||
await (this.$refs["deviceModal"] as any).remove();
|
||||
} finally {
|
||||
this.changing = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
29
assets/js/components/Config/defaultYaml/customHems.yaml
Normal file
29
assets/js/components/Config/defaultYaml/customHems.yaml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
## external limit via relay contact (binary on/off)
|
||||
type: relay
|
||||
maxpower: 4200 # total load limit while signal is active (W)
|
||||
limit: # input signal, plugin
|
||||
source: const
|
||||
value: false # 0/false = normal, 1/true = limit active
|
||||
#interval: 10s # polling interval
|
||||
#passthrough: # output, mirrors limit to a downstream system
|
||||
# source: http
|
||||
# uri: http://another-hems.local/api/limit?active={{ .dim }}
|
||||
# method: POST
|
||||
|
||||
## alternative limit sources examples
|
||||
#limit:
|
||||
# source: gpio
|
||||
# pin: 17
|
||||
# function: read
|
||||
#limit:
|
||||
# source: mqtt
|
||||
# topic: hems/limit/status
|
||||
#limit:
|
||||
# source: http
|
||||
# uri: http://steuerbox.local/api/limit
|
||||
# jq: .limited
|
||||
#limit:
|
||||
# source: modbus
|
||||
# uri: 192.168.179.200:4703
|
||||
# id: 3
|
||||
# register: { type: holding, decode: uint16, address: 8056 }
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
## external control via relay
|
||||
|
||||
#type: relay
|
||||
#maxPower: 4200 # limit loadpoints to 4.2 kW total
|
||||
#limit: # limit signal, plugin
|
||||
# source: mqtt
|
||||
# topic: hems/limit/status # 0/false = normal, 1/true = limit active
|
||||
|
||||
## external control via FNN Steuerbox
|
||||
|
||||
#type: fnn
|
||||
#maxDimPower: 4200 # limit consumption to 4.2 kW while dimmed (W4)
|
||||
#maxCurtailPower: 10000 # installed PV power, base for curtailment steps (W3/S1/S2)
|
||||
#w4: # dim signal, plugin
|
||||
# source: mqtt
|
||||
# topic: hems/w4/status # 0/false = normal, 1/true = limit active
|
||||
#w3: # curtail to 0% signal, plugin
|
||||
# source: mqtt
|
||||
# topic: hems/w3/status
|
||||
#s2: # curtail to 30% signal, plugin
|
||||
# source: mqtt
|
||||
# topic: hems/s2/status
|
||||
#s1: # curtail to 60% signal, plugin
|
||||
# source: mqtt
|
||||
# topic: hems/s1/status
|
||||
|
||||
## external control via EEBus
|
||||
|
||||
#type: eebus # general EEBus setup (cert gen) required
|
||||
#ski: "1234-5678-90AB-CDEF" # SKI of control box (grid operator)
|
||||
|
|
@ -38,7 +38,7 @@ export interface InfluxConfig {
|
|||
}
|
||||
|
||||
export interface HemsConfig {
|
||||
type: string;
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
export interface HemsStatus {
|
||||
|
|
@ -221,6 +221,10 @@ export enum ConfigType {
|
|||
export type ConfigVehicle = Entity;
|
||||
export type ConfigMessenger = Entity;
|
||||
|
||||
export interface ConfigHems extends Entity {
|
||||
deviceProduct?: string;
|
||||
}
|
||||
|
||||
// Configuration-specific types for device setup/configuration contexts
|
||||
export interface ConfigCharger extends Omit<Entity, "type"> {
|
||||
deviceProduct: string;
|
||||
|
|
@ -721,7 +725,14 @@ export interface SelectOption<T> {
|
|||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export type DeviceType = "charger" | "meter" | "vehicle" | "loadpoint" | "messenger" | "tariff";
|
||||
export type DeviceType =
|
||||
| "charger"
|
||||
| "meter"
|
||||
| "vehicle"
|
||||
| "loadpoint"
|
||||
| "messenger"
|
||||
| "tariff"
|
||||
| "hems";
|
||||
export type MeterType = "grid" | "pv" | "battery" | "charge" | "aux" | "ext" | "consumer";
|
||||
export type MeterTemplateUsage = "grid" | "pv" | "battery" | "charge" | "aux";
|
||||
export type TariffType = "grid" | "feedIn" | "co2" | "planner" | "solar";
|
||||
|
|
|
|||
|
|
@ -318,6 +318,20 @@
|
|||
<CircuitTags v-else :nodes="[circuitsRoot]" />
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<DeviceCard
|
||||
:title="$t('config.hems.title')"
|
||||
editable
|
||||
:error="hasClassError('hems')"
|
||||
:unconfigured="isUnconfigured(hemsTags)"
|
||||
data-testid="hems"
|
||||
@edit="openModal('hems')"
|
||||
>
|
||||
<template #icon><HemsIcon /></template>
|
||||
<template #tags>
|
||||
<p v-if="hemsLabel" class="my-2 fw-bold">{{ hemsLabel }}</p>
|
||||
<DeviceTags :tags="hemsTags" />
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<DeviceCard
|
||||
:title="$t('config.modbusproxy.title')"
|
||||
editable
|
||||
|
|
@ -331,19 +345,6 @@
|
|||
<DeviceTags :tags="modbusproxyTags" />
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<DeviceCard
|
||||
:title="$t('config.hems.title')"
|
||||
editable
|
||||
:error="hasClassError('hems')"
|
||||
:unconfigured="isUnconfigured(hemsTags)"
|
||||
data-testid="hems"
|
||||
@edit="openModal('hems')"
|
||||
>
|
||||
<template #icon><HemsIcon /></template>
|
||||
<template #tags>
|
||||
<DeviceTags :tags="hemsTags" />
|
||||
</template>
|
||||
</DeviceCard>
|
||||
<DeviceCard
|
||||
v-if="experimental"
|
||||
:title="`${$t('config.remote.title')} 🧪`"
|
||||
|
|
@ -452,7 +453,11 @@
|
|||
<MqttModal @changed="loadDirty" />
|
||||
<NetworkModal @changed="loadDirty" />
|
||||
<ControlModal @changed="loadDirty" />
|
||||
<HemsModal :yamlSource="hems?.yamlSource" @changed="loadDirty" />
|
||||
<HemsModal
|
||||
:id="hemsDevices[0]?.id"
|
||||
:yamlSource="hems?.yamlSource"
|
||||
@changed="hemsChanged"
|
||||
/>
|
||||
<ShmModal @changed="loadDirty" />
|
||||
<MessagingLegacyModal @changed="loadDirty" />
|
||||
<MessagingModal :messengers="messengers" @changed="loadDirty" />
|
||||
|
|
@ -551,6 +556,7 @@ import type {
|
|||
ConfigVehicle,
|
||||
ConfigCircuit,
|
||||
ConfigMessenger,
|
||||
ConfigHems,
|
||||
ConfigLoadpoint,
|
||||
ConfigMeter,
|
||||
Timeout,
|
||||
|
|
@ -562,7 +568,7 @@ import type {
|
|||
Notification,
|
||||
Remote,
|
||||
} from "@/types/evcc";
|
||||
import { CURRENCY } from "@/types/evcc";
|
||||
import { ConfigType, CURRENCY } from "@/types/evcc";
|
||||
import { circuitTree, type CircuitNode } from "@/utils/circuits";
|
||||
|
||||
type DeviceValuesMap = Record<DeviceType, Record<string, any>>;
|
||||
|
|
@ -653,6 +659,7 @@ export default defineComponent({
|
|||
loadpoints: [] as ConfigLoadpoint[],
|
||||
chargers: [] as ConfigCharger[],
|
||||
circuits: [] as ConfigCircuit[],
|
||||
hemsDevices: [] as ConfigHems[],
|
||||
tariffs: [] as any[], // ConfigTariff[] - tariff device entities
|
||||
tariffRefs: {
|
||||
grid: "",
|
||||
|
|
@ -814,11 +821,10 @@ export default defineComponent({
|
|||
return store.state?.hems;
|
||||
},
|
||||
hemsTags(): DeviceTags {
|
||||
const type = this.hems?.config?.type;
|
||||
const status = store.state?.hems?.status;
|
||||
if (!type && !status) {
|
||||
if (this.hemsDevices.length === 0 && !this.hems?.config?.configured) {
|
||||
return { configured: { value: false } };
|
||||
}
|
||||
const status = store.state?.hems?.status;
|
||||
if (!status) {
|
||||
return { configured: { value: true } };
|
||||
}
|
||||
|
|
@ -842,6 +848,13 @@ export default defineComponent({
|
|||
|
||||
return result;
|
||||
},
|
||||
hemsLabel(): string {
|
||||
const dev = this.hemsDevices[0];
|
||||
if (!dev) return "";
|
||||
if (dev.deviceProduct) return dev.deviceProduct;
|
||||
if (dev.type === ConfigType.Custom) return this.$t("config.hems.customOption");
|
||||
return "";
|
||||
},
|
||||
remote(): Remote | undefined {
|
||||
return store.state?.remote;
|
||||
},
|
||||
|
|
@ -1000,6 +1013,7 @@ export default defineComponent({
|
|||
await this.loadMessengers();
|
||||
await this.loadTariffs();
|
||||
await this.loadTariffRefs();
|
||||
await this.loadHems();
|
||||
await this.loadDirty();
|
||||
this.updateValues();
|
||||
},
|
||||
|
|
@ -1026,6 +1040,9 @@ export default defineComponent({
|
|||
async loadMeters() {
|
||||
this.meters = (await this.loadConfig("devices/meter")) || [];
|
||||
},
|
||||
async loadHems() {
|
||||
this.hemsDevices = (await this.loadConfig("devices/hems")) || [];
|
||||
},
|
||||
async loadCircuits() {
|
||||
this.circuits = (await this.loadConfig("devices/circuit")) || [];
|
||||
},
|
||||
|
|
@ -1123,6 +1140,10 @@ export default defineComponent({
|
|||
await this.loadDirty();
|
||||
this.updateValues();
|
||||
},
|
||||
async hemsChanged() {
|
||||
await this.loadHems();
|
||||
await this.loadDirty();
|
||||
},
|
||||
async loadpointChanged() {
|
||||
await this.loadLoadpoints();
|
||||
this.loadDirty();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/core/loadpoint"
|
||||
"github.com/evcc-io/evcc/hems/hems"
|
||||
"github.com/evcc-io/evcc/util/config"
|
||||
"github.com/evcc-io/evcc/util/templates"
|
||||
"github.com/spf13/cobra"
|
||||
|
|
@ -56,6 +57,8 @@ func runConfigDelete(cmd *cobra.Command, args []string) {
|
|||
deleteDevice[api.Tariff](c)
|
||||
case templates.Circuit:
|
||||
deleteDevice[api.Circuit](c)
|
||||
case templates.Hems:
|
||||
deleteDevice[hems.API](c)
|
||||
case templates.Loadpoint:
|
||||
deleteDevice[loadpoint.API](c)
|
||||
}
|
||||
|
|
|
|||
27
cmd/root.go
27
cmd/root.go
|
|
@ -16,6 +16,7 @@ import (
|
|||
"github.com/evcc-io/evcc/charger/ocpp"
|
||||
"github.com/evcc-io/evcc/core"
|
||||
"github.com/evcc-io/evcc/core/keys"
|
||||
"github.com/evcc-io/evcc/hems/hems"
|
||||
"github.com/evcc-io/evcc/messenger"
|
||||
"github.com/evcc-io/evcc/server"
|
||||
"github.com/evcc-io/evcc/server/db"
|
||||
|
|
@ -353,12 +354,18 @@ func runRoot(cmd *cobra.Command, args []string) {
|
|||
}
|
||||
|
||||
// start HEMS server
|
||||
var hemsInstance hems.API
|
||||
if err == nil {
|
||||
if hems, errConf := configureHEMS(&conf.HEMS, site); errConf == nil && hems != nil {
|
||||
hemsInstance, err = configureHEMS(&conf.HEMS, site)
|
||||
if err != nil {
|
||||
err = wrapErrorWithClass(ClassHEMS, err)
|
||||
} else if hemsInstance != nil {
|
||||
// republish when HEMS state updates
|
||||
hems.SetUpdated(func() {
|
||||
hemsInstance.SetUpdated(func() {
|
||||
valueChan <- util.Param{Key: keys.Hems, Val: globalconfig.ConfigStatus{
|
||||
Config: conf.HEMS.Redacted(),
|
||||
Config: struct {
|
||||
Configured bool `json:"configured"`
|
||||
}{hemsInstance != nil},
|
||||
YamlSource: yamlSource.hems,
|
||||
Status: struct {
|
||||
Dimmed *bool `json:"dimmed,omitempty"`
|
||||
|
|
@ -366,15 +373,13 @@ func runRoot(cmd *cobra.Command, args []string) {
|
|||
MaxConsumptionPower float64 `json:"maxConsumptionPower,omitempty"`
|
||||
MaxProductionPower *float64 `json:"maxProductionPower,omitempty"`
|
||||
}{
|
||||
Dimmed: hems.Dimmed(),
|
||||
Curtailed: hems.Curtailed(),
|
||||
MaxConsumptionPower: hems.MaxConsumptionPower(),
|
||||
MaxProductionPower: hems.MaxProductionPower(),
|
||||
Dimmed: hemsInstance.Dimmed(),
|
||||
Curtailed: hemsInstance.Curtailed(),
|
||||
MaxConsumptionPower: hemsInstance.MaxConsumptionPower(),
|
||||
MaxProductionPower: hemsInstance.MaxProductionPower(),
|
||||
},
|
||||
}}
|
||||
})
|
||||
} else {
|
||||
err = wrapErrorWithClass(ClassHEMS, errConf)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -429,7 +434,9 @@ func runRoot(cmd *cobra.Command, args []string) {
|
|||
}}
|
||||
|
||||
valueChan <- util.Param{Key: keys.Hems, Val: globalconfig.ConfigStatus{
|
||||
Config: conf.HEMS.Redacted(),
|
||||
Config: struct {
|
||||
Configured bool `json:"configured"`
|
||||
}{hemsInstance != nil},
|
||||
YamlSource: yamlSource.hems,
|
||||
}}
|
||||
valueChan <- util.Param{Key: keys.Tariffs, Val: globalconfig.ConfigStatus{
|
||||
|
|
|
|||
85
cmd/setup.go
85
cmd/setup.go
|
|
@ -779,38 +779,85 @@ func configureGo(conf []globalconfig.Go) error {
|
|||
|
||||
// setup HEMS
|
||||
func configureHEMS(conf *globalconfig.Hems, site *core.Site) (hemsapi.API, error) {
|
||||
// use yaml if configured
|
||||
if conf.Type == "" {
|
||||
if settings.Exists(keys.Hems) {
|
||||
*conf = globalconfig.Hems{}
|
||||
if err := settings.Yaml(keys.Hems, new(map[string]any), &conf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
yamlSource.hems = globalconfig.YamlSourceDb
|
||||
}
|
||||
} else {
|
||||
if conf.Type != "" {
|
||||
yamlSource.hems = globalconfig.YamlSourceFile
|
||||
|
||||
typ, other, err := config.CustomDevice(conf.Type, conf.Other)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot decode custom hems '%s': %w", conf.Type, err)
|
||||
}
|
||||
|
||||
instance, err := hems.NewFromConfig(context.TODO(), typ, other, site)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed configuring hems: %w", err)
|
||||
}
|
||||
|
||||
site.SetHEMS(instance)
|
||||
|
||||
go instance.Run()
|
||||
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
if conf.Type == "" {
|
||||
return nil, nil
|
||||
// migrate legacy keys.Hems setting (pre-UI) to a device-managed custom entry
|
||||
if err := migrateLegacyHemsSetting(); err != nil {
|
||||
return nil, fmt.Errorf("migrating legacy hems setting: %w", err)
|
||||
}
|
||||
|
||||
typ, other, err := config.CustomDevice(conf.Type, conf.Other)
|
||||
return configureDbHems(site)
|
||||
}
|
||||
|
||||
// migrateLegacyHemsSetting moves a keys.Hems YAML setting to a device-managed custom hems entry.
|
||||
func migrateLegacyHemsSetting() error {
|
||||
raw, err := settings.String(keys.Hems)
|
||||
if err != nil || strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err = config.AddConfig(templates.Hems,
|
||||
map[string]any{"yaml": raw},
|
||||
config.WithProperties(config.Properties{Type: "custom"}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot decode custom hems '%s': %w", conf.Type, err)
|
||||
return err
|
||||
}
|
||||
|
||||
hems, err := hems.NewFromConfig(context.TODO(), typ, other, site)
|
||||
log.INFO.Println("migrated legacy hems yaml setting to device config")
|
||||
return settings.Delete(keys.Hems)
|
||||
}
|
||||
|
||||
func configureDbHems(site *core.Site) (hemsapi.API, error) {
|
||||
dev, err := config.ConfigurationByClass(templates.Hems)
|
||||
if err != nil || dev == nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cc := dev.Named()
|
||||
|
||||
var instance hemsapi.API
|
||||
typ, other, err := config.CustomDevice(cc.Type, cc.Other)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed configuring hems: %w", err)
|
||||
err = &DeviceError{cc.Name, fmt.Errorf("cannot decode custom %s '%s': %w", typ, cc.Name, err)}
|
||||
} else {
|
||||
instance, err = hems.NewFromConfig(context.TODO(), typ, other, site)
|
||||
if err != nil {
|
||||
err = &DeviceError{cc.Name, fmt.Errorf("cannot create %s '%s': %w", typ, cc.Name, err)}
|
||||
}
|
||||
}
|
||||
|
||||
site.SetHEMS(hems)
|
||||
// register device even on factory error so UI can edit/delete the broken config
|
||||
if addErr := config.Hems().Add(config.NewConfigurableDevice(dev, instance)); addErr != nil && err == nil {
|
||||
err = &DeviceError{cc.Name, addErr}
|
||||
}
|
||||
|
||||
go hems.Run()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return hems, nil
|
||||
site.SetHEMS(instance)
|
||||
|
||||
go instance.Run()
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
// networkSettings reads/migrates network settings
|
||||
|
|
|
|||
52
cmd/setup_hems_test.go
Normal file
52
cmd/setup_hems_test.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/evcc-io/evcc/core/keys"
|
||||
"github.com/evcc-io/evcc/server/db"
|
||||
"github.com/evcc-io/evcc/server/db/settings"
|
||||
"github.com/evcc-io/evcc/util/config"
|
||||
"github.com/evcc-io/evcc/util/templates"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMigrateLegacyHemsSetting(t *testing.T) {
|
||||
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
|
||||
require.NoError(t, db.Instance.AutoMigrate(&config.Config{}))
|
||||
|
||||
const legacyYaml = `type: relay
|
||||
maxpower: 4200
|
||||
limit:
|
||||
source: mqtt
|
||||
topic: hems/limit/status
|
||||
`
|
||||
settings.SetString(keys.Hems, legacyYaml)
|
||||
|
||||
require.NoError(t, migrateLegacyHemsSetting())
|
||||
|
||||
require.False(t, settings.Exists(keys.Hems))
|
||||
|
||||
devices, err := config.ConfigurationsByClass(templates.Hems)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, devices, 1)
|
||||
require.Equal(t, "custom", devices[0].Type)
|
||||
require.Equal(t, legacyYaml, devices[0].Data["yaml"])
|
||||
|
||||
// second run is a noop
|
||||
require.NoError(t, migrateLegacyHemsSetting())
|
||||
devices, err = config.ConfigurationsByClass(templates.Hems)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, devices, 1)
|
||||
}
|
||||
|
||||
func TestMigrateLegacyHemsSetting_noop(t *testing.T) {
|
||||
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
|
||||
require.NoError(t, db.Instance.AutoMigrate(&config.Config{}))
|
||||
|
||||
require.NoError(t, migrateLegacyHemsSetting())
|
||||
|
||||
devices, err := config.ConfigurationsByClass(templates.Hems)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, devices)
|
||||
}
|
||||
|
|
@ -6,24 +6,26 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/evcc-io/evcc/core/site"
|
||||
"github.com/evcc-io/evcc/hems/eebus"
|
||||
"github.com/evcc-io/evcc/hems/fnn"
|
||||
"github.com/evcc-io/evcc/hems/config"
|
||||
_ "github.com/evcc-io/evcc/hems/eebus"
|
||||
_ "github.com/evcc-io/evcc/hems/fnn"
|
||||
"github.com/evcc-io/evcc/hems/hems"
|
||||
"github.com/evcc-io/evcc/hems/relay"
|
||||
_ "github.com/evcc-io/evcc/hems/relay"
|
||||
)
|
||||
|
||||
// NewFromConfig creates new HEMS from config
|
||||
var registry = config.Registry
|
||||
|
||||
// Types returns the list of types
|
||||
func Types() []string {
|
||||
return registry.Types()
|
||||
}
|
||||
|
||||
// NewFromConfig creates hems from configuration
|
||||
func NewFromConfig(ctx context.Context, typ string, other map[string]any, site site.API) (hems.API, error) {
|
||||
switch strings.ToLower(typ) {
|
||||
case "sma", "shm", "semp":
|
||||
return nil, errors.New("breaking change: Sunny Home Manager integration is always on. See https://github.com/evcc-io/evcc/releases and https://docs.evcc.io/en/docs/integrations/sma-sunny-home-manager")
|
||||
case "eebus":
|
||||
return eebus.NewFromConfig(ctx, other, site)
|
||||
case "fnn", "fnn-3":
|
||||
return fnn.NewFromConfig(ctx, other, site)
|
||||
case "relay":
|
||||
return relay.NewFromConfig(ctx, other, site)
|
||||
default:
|
||||
return nil, errors.New("unknown hems: " + typ)
|
||||
return config.NewFromConfig(ctx, typ, other, site)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
35
hems/config/config.go
Normal file
35
hems/config/config.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/evcc-io/evcc/core/site"
|
||||
"github.com/evcc-io/evcc/hems/hems"
|
||||
reg "github.com/evcc-io/evcc/hems/registry"
|
||||
)
|
||||
|
||||
var Registry = reg.New[hems.API]("hems")
|
||||
|
||||
// AddCtx registers a factory returning a concrete type that implements hems.API
|
||||
func AddCtx[T hems.API](name string, factory func(context.Context, map[string]any, site.API) (T, error)) {
|
||||
Registry.AddCtx(name, func(ctx context.Context, other map[string]any, site site.API) (hems.API, error) {
|
||||
return factory(ctx, other, site)
|
||||
})
|
||||
}
|
||||
|
||||
// NewFromConfig creates hems from configuration
|
||||
func NewFromConfig(ctx context.Context, typ string, other map[string]any, site site.API) (hems.API, error) {
|
||||
factory, err := Registry.Get(strings.ToLower(typ))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
v, err := factory(ctx, other, site)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot create hems type '%s': %w", typ, err)
|
||||
}
|
||||
|
||||
return v, nil
|
||||
}
|
||||
|
|
@ -8,12 +8,17 @@ import (
|
|||
ucapi "github.com/enbility/eebus-go/usecases/api"
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/core/site"
|
||||
"github.com/evcc-io/evcc/hems/config"
|
||||
"github.com/evcc-io/evcc/hems/smartgrid"
|
||||
"github.com/evcc-io/evcc/plugin"
|
||||
"github.com/evcc-io/evcc/server/eebus"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
)
|
||||
|
||||
func init() {
|
||||
config.AddCtx("eebus", NewFromConfig)
|
||||
}
|
||||
|
||||
type EEBus struct {
|
||||
mux sync.RWMutex
|
||||
log *util.Logger
|
||||
|
|
@ -63,7 +68,10 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*E
|
|||
Interval time.Duration
|
||||
}{
|
||||
Limits: Limits{
|
||||
ContractualConsumptionNominalMax: 24800,
|
||||
// contractual max power at the grid connection point reported to the control box
|
||||
// (EEBus LPC, EMS device type). Default: standard 3x35A x 230V house connection.
|
||||
// This is the connection capacity, not the SteuVE Pmin (see failsafe limit below).
|
||||
ContractualConsumptionNominalMax: 24150, // 3 * 35A * 230V
|
||||
FailsafeConsumptionActivePowerLimit: 4200,
|
||||
|
||||
ProductionNominalMax: 0,
|
||||
|
|
|
|||
|
|
@ -9,11 +9,17 @@ import (
|
|||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/core/site"
|
||||
"github.com/evcc-io/evcc/hems/config"
|
||||
"github.com/evcc-io/evcc/hems/smartgrid"
|
||||
"github.com/evcc-io/evcc/plugin"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
)
|
||||
|
||||
func init() {
|
||||
config.AddCtx("fnn", NewFromConfig)
|
||||
config.AddCtx("fnn-3", NewFromConfig)
|
||||
}
|
||||
|
||||
// NewFromConfig creates an FNN HEMS from generic config.
|
||||
func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*Fnn, error) {
|
||||
cc := struct {
|
||||
|
|
|
|||
52
hems/registry/registry.go
Normal file
52
hems/registry/registry.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
|
||||
"github.com/evcc-io/evcc/core/site"
|
||||
)
|
||||
|
||||
type (
|
||||
// TODO check is this can be abstracted into a generic type parameter
|
||||
factoryFunc[T any] func(context.Context, map[string]any, site.API) (T, error)
|
||||
|
||||
registry[T any] struct {
|
||||
typ string
|
||||
data map[string]factoryFunc[T]
|
||||
}
|
||||
)
|
||||
|
||||
func (r registry[T]) Add(name string, factory func(map[string]any, site.API) (T, error)) {
|
||||
r.AddCtx(name, func(_ context.Context, cc map[string]any, site site.API) (T, error) {
|
||||
return factory(cc, site)
|
||||
})
|
||||
}
|
||||
|
||||
func (r registry[T]) AddCtx(name string, factory factoryFunc[T]) {
|
||||
if _, exists := r.data[name]; exists {
|
||||
panic(fmt.Sprintf("cannot register duplicate %s type: %s", r.typ, name))
|
||||
}
|
||||
r.data[name] = factory
|
||||
}
|
||||
|
||||
func (r registry[T]) Get(name string) (factoryFunc[T], error) {
|
||||
factory, exists := r.data[name]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("invalid %s type: %s", r.typ, name)
|
||||
}
|
||||
return factory, nil
|
||||
}
|
||||
|
||||
func (r registry[T]) Types() []string {
|
||||
return slices.Sorted(maps.Keys(r.data))
|
||||
}
|
||||
|
||||
func New[T any](typ string) registry[T] {
|
||||
return registry[T]{
|
||||
typ: typ,
|
||||
data: make(map[string]factoryFunc[T]),
|
||||
}
|
||||
}
|
||||
|
|
@ -9,11 +9,16 @@ import (
|
|||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/core/site"
|
||||
"github.com/evcc-io/evcc/hems/config"
|
||||
"github.com/evcc-io/evcc/hems/smartgrid"
|
||||
"github.com/evcc-io/evcc/plugin"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
)
|
||||
|
||||
func init() {
|
||||
config.AddCtx("relay", NewFromConfig)
|
||||
}
|
||||
|
||||
type Relay struct {
|
||||
mu sync.Mutex
|
||||
log *util.Logger
|
||||
|
|
|
|||
22
hems/template.go
Normal file
22
hems/template.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package hems
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/evcc-io/evcc/core/site"
|
||||
"github.com/evcc-io/evcc/hems/hems"
|
||||
"github.com/evcc-io/evcc/util/templates"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registry.AddCtx("template", NewHemsFromTemplateConfig)
|
||||
}
|
||||
|
||||
func NewHemsFromTemplateConfig(ctx context.Context, other map[string]any, site site.API) (hems.API, error) {
|
||||
instance, err := templates.RenderInstance(templates.Hems, other)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return NewFromConfig(ctx, instance.Type, instance.Other, site)
|
||||
}
|
||||
|
|
@ -308,6 +308,7 @@
|
|||
"titleEdit": "Netzzähler bearbeiten"
|
||||
},
|
||||
"hems": {
|
||||
"changeConfirm": "Bestehende Konfiguration wird gelöscht. Fortfahren?",
|
||||
"csv": {
|
||||
"created": "Startzeit",
|
||||
"finished": "Endzeit",
|
||||
|
|
@ -315,10 +316,13 @@
|
|||
"limitpower": "Limit (kW)",
|
||||
"type": "Typ"
|
||||
},
|
||||
"description": "Leistungsbegrenzung durch externe Systeme (z.B. §14a EnWG, §9 EEG Schnittstelle oder übergeordnetes Energiemanagementsystem). Spielt mit dem Lastmanagement Feature zusammen.",
|
||||
"customOption": "Benutzerdefinierte Integration",
|
||||
"description": "Leistungsbegrenzung durch externe Systeme (z.B. §14a EnWG, §9 EEG Schnittstelle oder übergeordnetes Energiemanagementsystem). Spielt mit dem Lastmanagement Feature zusammen. Das Abregeln der Solarproduktion wird ebenfalls für ausgewählte Geräte unterstützt. Der konkrete Abregelungswert muss in der jeweiligen Wechselrichter-Konfiguration eingestellt werden.",
|
||||
"downloadCsv": "CSV herunterladen",
|
||||
"eventsRecorded": "{count} Netzbegrenzungen gespeichert.",
|
||||
"lastEvent": "Zuletzt {timeAgo}.",
|
||||
"integrations": "Integrationen",
|
||||
"lastEvent": "Letzter Eintrag {timeAgo}.",
|
||||
"template": "Integration",
|
||||
"title": "Externe Begrenzung"
|
||||
},
|
||||
"icon": {
|
||||
|
|
|
|||
|
|
@ -307,6 +307,7 @@
|
|||
"titleEdit": "Edit Grid Meter"
|
||||
},
|
||||
"hems": {
|
||||
"changeConfirm": "Existing configuration will be deleted. Continue?",
|
||||
"csv": {
|
||||
"created": "Created",
|
||||
"finished": "Finished",
|
||||
|
|
@ -314,10 +315,14 @@
|
|||
"limitpower": "Limit (kW)",
|
||||
"type": "Type"
|
||||
},
|
||||
"description": "Power limitation by external systems (e.g. §14a EnWG, §9 EEG interface or higher-level energy management system). Works together with the load management feature.",
|
||||
"customOption": "User-defined integration",
|
||||
"description": "Power limitation by external systems (e.g. §14a EnWG, §9 EEG interface or higher-level energy management system). Works together with the load management feature. Curtailing solar production is also supported for selected devices. The actual curtailment limit must be set on each inverter configuration.",
|
||||
"downloadCsv": "Download CSV",
|
||||
"eventsRecorded": "Recorded {count} grid limitation events.",
|
||||
"generic": "Generic integrations",
|
||||
"integrations": "Integrations",
|
||||
"lastEvent": "Most recent {timeAgo}.",
|
||||
"template": "Integration",
|
||||
"title": "External Limit"
|
||||
},
|
||||
"icon": {
|
||||
|
|
|
|||
|
|
@ -77,8 +77,7 @@ func init() {
|
|||
// NewEEBusFromConfig creates an EEBus meter from generic config
|
||||
func NewEEBusFromConfig(ctx context.Context, other map[string]any) (api.Meter, error) {
|
||||
var cc struct {
|
||||
Ski string
|
||||
Ip string
|
||||
Ski, Ip string
|
||||
Usage *templates.Usage
|
||||
Timeout_ time.Duration `mapstructure:"timeout"` // TODO deprecated
|
||||
}
|
||||
|
|
|
|||
|
|
@ -298,11 +298,11 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, pub publisher, cache *uti
|
|||
"devicestatus": {"GET", "/devices/{class:[a-z]+}/{name:[a-zA-Z0-9_.:-]+}/status", deviceStatusHandler},
|
||||
"dirty": {"GET", "/dirty", getHandler(ConfigDirty)},
|
||||
"evccyaml": {"GET", "/evcc.yaml", configYamlHandler(configFile)},
|
||||
"newdevice": {"POST", "/devices/{class:[a-z]+}", newDeviceHandler(auth)},
|
||||
"updatedevice": {"PUT", "/devices/{class:[a-z]+}/{id:[0-9.]+}", updateDeviceHandler(auth)},
|
||||
"newdevice": {"POST", "/devices/{class:[a-z]+}", newDeviceHandler(site, auth)},
|
||||
"updatedevice": {"PUT", "/devices/{class:[a-z]+}/{id:[0-9.]+}", updateDeviceHandler(site, auth)},
|
||||
"deletedevice": {"DELETE", "/devices/{class:[a-z]+}/{id:[0-9.]+}", deleteDeviceHandler(site)},
|
||||
"testconfig": {"POST", "/test/{class:[a-z]+}", testConfigHandler(auth)},
|
||||
"testmerged": {"POST", "/test/{class:[a-z]+}/merge/{id:[0-9.]+}", testConfigHandler(auth)},
|
||||
"testconfig": {"POST", "/test/{class:[a-z]+}", testConfigHandler(site, auth)},
|
||||
"testmerged": {"POST", "/test/{class:[a-z]+}/merge/{id:[0-9.]+}", testConfigHandler(site, auth)},
|
||||
"interval": {"POST", "/interval/{value:[0-9.]+}", settingsSetDurationHandler(keys.Interval, pub)},
|
||||
"updatesponsortoken": {"POST", "/sponsortoken", updateSponsortokenHandler(pub)},
|
||||
"deletesponsortoken": {"DELETE", "/sponsortoken", deleteSponsorTokenHandler(pub)},
|
||||
|
|
|
|||
|
|
@ -12,11 +12,14 @@ import (
|
|||
"time"
|
||||
|
||||
"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/hems"
|
||||
hemsapi "github.com/evcc-io/evcc/hems/hems"
|
||||
"github.com/evcc-io/evcc/messenger"
|
||||
"github.com/evcc-io/evcc/meter"
|
||||
"github.com/evcc-io/evcc/server/db/settings"
|
||||
|
|
@ -78,6 +81,9 @@ func devicesConfigHandler(w http.ResponseWriter, r *http.Request) {
|
|||
case templates.Circuit:
|
||||
res, err = devicesConfig(class, config.Circuits(), hidePrivate)
|
||||
|
||||
case templates.Hems:
|
||||
res, err = devicesConfig(class, config.Hems(), hidePrivate)
|
||||
|
||||
case templates.Tariff:
|
||||
res, err = devicesConfig(class, config.Tariffs(), hidePrivate)
|
||||
|
||||
|
|
@ -204,6 +210,9 @@ func deviceConfigHandler(w http.ResponseWriter, r *http.Request) {
|
|||
case templates.Circuit:
|
||||
res, err = deviceConfig(class, id, config.Circuits(), hidePrivate)
|
||||
|
||||
case templates.Hems:
|
||||
res, err = deviceConfig(class, id, config.Hems(), hidePrivate)
|
||||
|
||||
case templates.Tariff:
|
||||
res, err = deviceConfig(class, id, config.Tariffs(), hidePrivate)
|
||||
|
||||
|
|
@ -266,6 +275,9 @@ func deviceStatusHandler(w http.ResponseWriter, r *http.Request) {
|
|||
case templates.Circuit:
|
||||
instance, err = deviceStatus(name, config.Circuits())
|
||||
|
||||
case templates.Hems:
|
||||
err = api.ErrNotAvailable
|
||||
|
||||
case templates.Tariff:
|
||||
instance, err = deviceStatus(name, config.Tariffs())
|
||||
|
||||
|
|
@ -308,8 +320,24 @@ func newDevice[T any](ctx context.Context, class templates.Class, req configReq,
|
|||
return &conf, h.Add(config.NewConfigurableDevice(&conf, instance))
|
||||
}
|
||||
|
||||
// newHemsFactory adapts hems.NewFromConfig to the generic newFromConfFunc signature.
|
||||
func newHemsFactory(site site.API) newFromConfFunc[hemsapi.API] {
|
||||
return func(ctx context.Context, typ string, other map[string]any) (hemsapi.API, error) {
|
||||
return hems.NewFromConfig(ctx, typ, other, site)
|
||||
}
|
||||
}
|
||||
|
||||
// HemsStatus returns the publish payload for the configured flag.
|
||||
func HemsStatus(configured bool) globalconfig.ConfigStatus {
|
||||
return globalconfig.ConfigStatus{
|
||||
Config: struct {
|
||||
Configured bool `json:"configured"`
|
||||
}{configured},
|
||||
}
|
||||
}
|
||||
|
||||
// newDeviceHandler creates a new device by class
|
||||
func newDeviceHandler(authObject auth.Auth) http.HandlerFunc {
|
||||
func newDeviceHandler(site site.API, authObject auth.Auth) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
|
|
@ -347,6 +375,13 @@ func newDeviceHandler(authObject auth.Auth) http.HandlerFunc {
|
|||
case templates.Circuit:
|
||||
conf, err = newDevice(ctx, class, req, circuit.NewFromConfig, config.Circuits(), force)
|
||||
|
||||
case templates.Hems:
|
||||
if existing, _ := config.ConfigurationByClass(templates.Hems); existing != nil {
|
||||
err = errors.New("hems already configured")
|
||||
} else {
|
||||
conf, err = newDevice(ctx, class, req, newHemsFactory(site), config.Hems(), force)
|
||||
}
|
||||
|
||||
case templates.Tariff:
|
||||
conf, err = newDevice(ctx, class, req, tariff.NewFromConfig, config.Tariffs(), force)
|
||||
|
||||
|
|
@ -365,6 +400,10 @@ func newDeviceHandler(authObject auth.Auth) http.HandlerFunc {
|
|||
|
||||
setConfigDirty()
|
||||
|
||||
if class == templates.Hems {
|
||||
site.Publish(keys.Hems, HemsStatus(true))
|
||||
}
|
||||
|
||||
res := struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
|
|
@ -395,7 +434,7 @@ func updateDevice[T any](ctx context.Context, id int, class templates.Class, req
|
|||
}
|
||||
|
||||
// updateDeviceHandler updates database device's configuration by class
|
||||
func updateDeviceHandler(authObject auth.Auth) http.HandlerFunc {
|
||||
func updateDeviceHandler(site site.API, authObject auth.Auth) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
|
|
@ -438,6 +477,9 @@ func updateDeviceHandler(authObject auth.Auth) http.HandlerFunc {
|
|||
case templates.Circuit:
|
||||
err = updateDevice(ctx, id, class, req, circuit.NewFromConfig, config.Circuits(), force)
|
||||
|
||||
case templates.Hems:
|
||||
err = updateDevice(ctx, id, class, req, newHemsFactory(site), config.Hems(), force)
|
||||
|
||||
case templates.Tariff:
|
||||
err = updateDevice(ctx, id, class, req, tariff.NewFromConfig, config.Tariffs(), force)
|
||||
|
||||
|
|
@ -612,6 +654,9 @@ func deleteDeviceHandler(site site.API) func(w http.ResponseWriter, r *http.Requ
|
|||
}
|
||||
}
|
||||
|
||||
case templates.Hems:
|
||||
err = deleteDevice(id, config.Hems())
|
||||
|
||||
case templates.Tariff:
|
||||
err = deleteDevice(id, config.Tariffs())
|
||||
|
||||
|
|
@ -631,6 +676,10 @@ func deleteDeviceHandler(site site.API) func(w http.ResponseWriter, r *http.Requ
|
|||
return
|
||||
}
|
||||
|
||||
if class == templates.Hems {
|
||||
site.Publish(keys.Hems, HemsStatus(false))
|
||||
}
|
||||
|
||||
res := struct {
|
||||
ID int `json:"id"`
|
||||
}{
|
||||
|
|
@ -657,7 +706,7 @@ func testConfig[T any](ctx context.Context, id int, class templates.Class, req c
|
|||
}
|
||||
|
||||
// testConfigHandler tests a configuration by class
|
||||
func testConfigHandler(authObject auth.Auth) http.HandlerFunc {
|
||||
func testConfigHandler(site site.API, authObject auth.Auth) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
|
|
@ -703,6 +752,9 @@ func testConfigHandler(authObject auth.Auth) http.HandlerFunc {
|
|||
case templates.Circuit:
|
||||
instance, err = testConfig(ctx, id, class, req, circuit.NewFromConfig, config.Circuits())
|
||||
|
||||
case templates.Hems:
|
||||
instance, err = testConfig(ctx, id, class, req, newHemsFactory(site), config.Hems())
|
||||
|
||||
case templates.Tariff:
|
||||
instance, err = testConfig(ctx, id, class, req, tariff.NewFromConfig, config.Tariffs())
|
||||
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@ package definition
|
|||
|
||||
import "embed"
|
||||
|
||||
//go:embed charger/*.yaml meter/*.yaml vehicle/*.yaml tariff/*.yaml messenger/*.yaml circuit/*.yaml
|
||||
//go:embed charger/*.yaml meter/*.yaml vehicle/*.yaml tariff/*.yaml messenger/*.yaml circuit/*.yaml hems/*.yaml
|
||||
var YamlTemplates embed.FS
|
||||
|
|
|
|||
82
templates/definition/hems/fnn-eebus.yaml
Normal file
82
templates/definition/hems/fnn-eebus.yaml
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
template: fnn-eebus
|
||||
products:
|
||||
- description:
|
||||
generic: FNN-Steuerbox via EEBus LPC&LPP (§ 14a EnWG, § 9 EEG)
|
||||
params:
|
||||
- preset: eebus
|
||||
- name: ski
|
||||
help:
|
||||
en: SKI of the FNN control box (shown in its web interface)
|
||||
de: SKI der FNN-Steuerbox (im Webinterface der Steuerbox einsehbar)
|
||||
- name: consumptionnominalmax
|
||||
type: float
|
||||
unit: W
|
||||
advanced: true
|
||||
description:
|
||||
en: Max. consumption power (grid connection)
|
||||
de: Max. Bezugsleistung (Netzanschluss)
|
||||
help:
|
||||
en: Contractual maximum power of the grid connection point reported to the control box. This is the connection capacity agreed with the grid operator, NOT the minimum consumption power (Pmin) of the control box. Default 24.15 kVA (3x35A).
|
||||
de: Vertragliche Gesamtmaximalleistung des Netzanschlusspunkts, die der Steuerbox gemeldet wird. Dies ist die mit dem Netzbetreiber vereinbarte Anschlusskapazität, NICHT die SteuVE-Mindestleistung (Pmin). Standard 24,15 kVA (3x35A).
|
||||
- name: failsafeconsumptionpower
|
||||
type: float
|
||||
unit: W
|
||||
advanced: true
|
||||
description:
|
||||
en: Failsafe consumption limit (Pmin, LPC)
|
||||
de: Failsafe-Bezugsgrenze der SteuVE (Pmin)
|
||||
help:
|
||||
en: Failsafe minimum consumption power (Pmin) for controllable loads applied when the heartbeat from the control box is lost; it is overwritten by the control box once it transmits its own failsafe values. (Default 4.2 kW)
|
||||
de: Verbleibende Mindestbezugsleistung (Pmin) für steuerbare Verbrauchseinrichtungen, die bei ausbleibendem Heartbeat der Steuerbox angewendet wird. Dies ist die per GZF berechnete Mindestbezugsleistung nach BK6-22-300; wird von der Steuerbox überschrieben, sobald diese eigene Failsafe-Werte überträgt. (Standard 4,2 kW nach § 14a EnWG)
|
||||
- name: productionnominalmax
|
||||
type: float
|
||||
unit: W
|
||||
advanced: true
|
||||
description:
|
||||
en: Installed generator power (Wp)
|
||||
de: Installierte Generatorleistung (Wp)
|
||||
help:
|
||||
en: Rated generator/module power (Wp) of the installation.
|
||||
de: Gesamtnennleistung bzw. Modul- oder Generatorleistung der Anlage (Bezugsgröße nach § 9 EEG).
|
||||
- name: failsafeproductionpower
|
||||
type: float
|
||||
unit: W
|
||||
advanced: true
|
||||
description:
|
||||
en: Failsafe production limit (LPP)
|
||||
de: Failsafe-Einspeisebegrenzung
|
||||
help:
|
||||
en: Remaining maximum feed-in power applied when the heartbeat from the control box is lost. Initial value; it is overwritten by the control box once it transmits its own failsafe values.
|
||||
de: Verbleibende maximale Einspeiseleistung, die bei verlorenem Heartbeat der Steuerbox angewendet wird. Initialwert; wird von der Steuerbox überschrieben, sobald diese eigene Failsafe-Werte überträgt.
|
||||
- name: failsafeduration
|
||||
type: duration
|
||||
advanced: true
|
||||
description:
|
||||
en: Failsafe minimum duration
|
||||
de: Failsafe-Mindestdauer
|
||||
help:
|
||||
en: Minimum time the failsafe limit stays active once entered. Initial value (default 2h); the control box overwrites it once it transmits its own value.
|
||||
de: Mindestdauer, für die der Failsafe-Wert nach Aktivierung gehalten wird. Initialwert (Standard 2h); wird von der Steuerbox überschrieben, sobald diese einen eigenen Wert überträgt.
|
||||
- name: passthroughpin
|
||||
type: int
|
||||
advanced: true
|
||||
description:
|
||||
en: Passthrough GPIO pin (LPC)
|
||||
de: Passthrough GPIO-Pin (SteuVE dimmen)
|
||||
help:
|
||||
en: Output pin that mirrors the active LPC signal to an external system
|
||||
de: Ausgangspin, der das aktive Dimmsignal an ein externes System weiterleitet
|
||||
render: |
|
||||
type: eebus
|
||||
ski: {{ .ski }}
|
||||
contractualconsumptionnominalmax: {{ .consumptionnominalmax }}
|
||||
failsafeconsumptionactivepowerlimit: {{ .failsafeconsumptionpower }}
|
||||
productionnominalmax: {{ .productionnominalmax }}
|
||||
failsafeproductionactivepowerlimit: {{ .failsafeproductionpower }}
|
||||
failsafedurationminimum: {{ .failsafeduration }}
|
||||
{{- if .passthroughpin }}
|
||||
passthrough:
|
||||
source: gpio
|
||||
pin: {{ .passthroughpin }}
|
||||
function: write
|
||||
{{- end }}
|
||||
83
templates/definition/hems/fnn-gpio.yaml
Normal file
83
templates/definition/hems/fnn-gpio.yaml
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
template: fnn-gpio
|
||||
products:
|
||||
- description:
|
||||
generic: FNN-Steuerbox via GPIO (§ 14a EnWG, § 9 EEG)
|
||||
params:
|
||||
- name: maxconsumptionpower
|
||||
type: float
|
||||
unit: W
|
||||
required: true
|
||||
description:
|
||||
en: Guaranteed minimum power (Pmin)
|
||||
de: Garantierte Mindestleistung (Pmin)
|
||||
help:
|
||||
en: Power limit applied to the root circuit while the limit is active.
|
||||
de: Leistungsbegrenzung des Hauptstromkreises während die Limitierung aktiv ist.
|
||||
- name: w4pin
|
||||
type: int
|
||||
required: true
|
||||
description:
|
||||
en: W4 GPIO pin
|
||||
de: W4 GPIO-Pin
|
||||
help:
|
||||
en: Input pin for the W4 signal (Dimmer for controllable loads)
|
||||
de: Eingangspin für das W4-Signal (Dimmen der steuerbaren Verbrauchseinrichtungen)
|
||||
- name: maxproductionpower
|
||||
type: float
|
||||
unit: W
|
||||
required: true
|
||||
description:
|
||||
en: Installed generator power (Wp)
|
||||
de: Installierte Generatorleistung (Wp)
|
||||
help:
|
||||
en: Rated generator/module power (Wp) of the installation.
|
||||
de: Gesamtnennleistung bzw. Modul- oder Generatorleistung der Anlage (Bezugsgröße nach § 9 EEG).
|
||||
- name: w3pin
|
||||
type: int
|
||||
required: true
|
||||
description:
|
||||
en: W3 GPIO pin
|
||||
de: W3 GPIO-Pin
|
||||
help:
|
||||
en: Input pin for the W3 signal (Zero export, 0%)
|
||||
de: Eingangspin für das W3-Signal (Keine Netzeinspeisung, 0%)
|
||||
- name: s1pin
|
||||
type: int
|
||||
description:
|
||||
en: S1 GPIO pin
|
||||
de: S1 GPIO-Pin
|
||||
help:
|
||||
en: Input pin for the S1 signal (60% export power limit)
|
||||
de: Eingangspin für das S1-Signal (Reduzierung der Einspeiseleistung auf 60%)
|
||||
- name: s2pin
|
||||
type: int
|
||||
description:
|
||||
en: S2 GPIO pin
|
||||
de: S2 GPIO-Pin
|
||||
help:
|
||||
en: Input pin for the S2 signal (30% export power limit)
|
||||
de: Eingangspin für das S2-Signal (Reduzierung der Einspeiseleistung auf 30%)
|
||||
render: |
|
||||
type: fnn
|
||||
maxdimpower: {{ .maxconsumptionpower }}
|
||||
w4:
|
||||
source: gpio
|
||||
pin: {{ .w4pin }}
|
||||
function: read
|
||||
maxcurtailpower: {{ .maxproductionpower }}
|
||||
w3:
|
||||
source: gpio
|
||||
pin: {{ .w3pin }}
|
||||
function: read
|
||||
{{- if .s1pin }}
|
||||
s1:
|
||||
source: gpio
|
||||
pin: {{ .s1pin }}
|
||||
function: read
|
||||
{{- end }}
|
||||
{{- if .s2pin }}
|
||||
s2:
|
||||
source: gpio
|
||||
pin: {{ .s2pin }}
|
||||
function: read
|
||||
{{- end }}
|
||||
72
templates/definition/hems/hemspro-eebus.yaml
Normal file
72
templates/definition/hems/hemspro-eebus.yaml
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
template: hemspro-eebus
|
||||
products:
|
||||
- brand: Awatt Electronic
|
||||
description:
|
||||
generic: HEMS PRO (EEBus)
|
||||
params:
|
||||
- preset: eebus
|
||||
- name: ski
|
||||
help:
|
||||
en: SKI of the FNN control box (shown in its web interface)
|
||||
de: SKI der FNN-Steuerbox (im Webinterface der Steuerbox einsehbar)
|
||||
- name: consumptionnominalmax
|
||||
type: float
|
||||
unit: W
|
||||
advanced: true
|
||||
description:
|
||||
en: Max. consumption power (grid connection)
|
||||
de: Max. Bezugsleistung (Netzanschluss)
|
||||
help:
|
||||
en: Contractual maximum power of the grid connection point reported to the control box. This is the connection capacity agreed with the grid operator, NOT the minimum consumption power (Pmin) of the control box. Default 24.15 kVA (3x35A).
|
||||
de: Vertragliche Gesamtmaximalleistung des Netzanschlusspunkts, die der Steuerbox gemeldet wird. Dies ist die mit dem Netzbetreiber vereinbarte Anschlusskapazität, NICHT die SteuVE-Mindestleistung (Pmin). Standard 24,15 kVA (3x35A).
|
||||
- name: failsafeconsumptionpower
|
||||
type: float
|
||||
unit: W
|
||||
advanced: true
|
||||
description:
|
||||
en: Failsafe consumption limit (Pmin, LPC)
|
||||
de: Failsafe-Bezugsgrenze der SteuVE (Pmin)
|
||||
help:
|
||||
en: Failsafe minimum consumption power (Pmin) for controllable loads applied when the heartbeat from the control box is lost; it is overwritten by the control box once it transmits its own failsafe values. (Default 4.2 kW)
|
||||
de: Verbleibende Mindestbezugsleistung (Pmin) für steuerbare Verbrauchseinrichtungen, die bei ausbleibendem Heartbeat der Steuerbox angewendet wird. Dies ist die per GZF berechnete Mindestbezugsleistung nach BK6-22-300; wird von der Steuerbox überschrieben, sobald diese eigene Failsafe-Werte überträgt. (Standard 4,2 kW nach § 14a EnWG)
|
||||
- name: productionnominalmax
|
||||
type: float
|
||||
unit: W
|
||||
advanced: true
|
||||
description:
|
||||
en: Installed generator power (Wp)
|
||||
de: Installierte Generatorleistung (Wp)
|
||||
help:
|
||||
en: Rated generator/module power (Wp) of the installation.
|
||||
de: Gesamtnennleistung bzw. Modul- oder Generatorleistung der Anlage (Bezugsgröße nach § 9 EEG).
|
||||
- name: failsafeproductionpower
|
||||
type: float
|
||||
unit: W
|
||||
advanced: true
|
||||
description:
|
||||
en: Failsafe production limit (LPP)
|
||||
de: Failsafe-Einspeisebegrenzung
|
||||
help:
|
||||
en: Remaining maximum feed-in power applied when the heartbeat from the control box is lost. Initial value; it is overwritten by the control box once it transmits its own failsafe values.
|
||||
de: Verbleibende maximale Einspeiseleistung, die bei verlorenem Heartbeat der Steuerbox angewendet wird. Initialwert; wird von der Steuerbox überschrieben, sobald diese eigene Failsafe-Werte überträgt.
|
||||
- name: failsafeduration
|
||||
type: duration
|
||||
advanced: true
|
||||
description:
|
||||
en: Failsafe minimum duration
|
||||
de: Failsafe-Mindestdauer
|
||||
help:
|
||||
en: Minimum time the failsafe limit stays active once entered. Initial value (default 2h); the control box overwrites it once it transmits its own value.
|
||||
de: Mindestdauer, für die der Failsafe-Wert nach Aktivierung gehalten wird. Initialwert (Standard 2h); wird von der Steuerbox überschrieben, sobald diese einen eigenen Wert überträgt.
|
||||
render: |
|
||||
type: eebus
|
||||
ski: {{ .ski }}
|
||||
contractualconsumptionnominalmax: {{ .consumptionnominalmax }}
|
||||
failsafeconsumptionactivepowerlimit: {{ .failsafeconsumptionpower }}
|
||||
productionnominalmax: {{ .productionnominalmax }}
|
||||
failsafeproductionactivepowerlimit: {{ .failsafeproductionpower }}
|
||||
failsafedurationminimum: {{ .failsafeduration }}
|
||||
passthrough:
|
||||
source: gpio
|
||||
pin: 19
|
||||
function: write
|
||||
46
templates/definition/hems/hemspro-gpio.yaml
Normal file
46
templates/definition/hems/hemspro-gpio.yaml
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
template: hemspro-gpio
|
||||
products:
|
||||
- brand: Awatt Electronic
|
||||
description:
|
||||
generic: HEMS PRO (FNN-Schaltkontakte)
|
||||
params:
|
||||
- name: maxconsumptionpower
|
||||
type: float
|
||||
unit: W
|
||||
required: true
|
||||
description:
|
||||
en: Guaranteed minimum power (Pmin)
|
||||
de: Garantierte Mindestleistung (Pmin)
|
||||
help:
|
||||
en: Power limit applied to the root circuit while the limit is active.
|
||||
de: Leistungsbegrenzung des Hauptstromkreises während die Limitierung aktiv ist.
|
||||
- name: maxproductionpower
|
||||
type: float
|
||||
unit: W
|
||||
required: true
|
||||
description:
|
||||
en: Installed generator power (Wp)
|
||||
de: Installierte Generatorleistung (Wp)
|
||||
help:
|
||||
en: Rated generator/module power (Wp) of the installation.
|
||||
de: Gesamtnennleistung bzw. Modul- oder Generatorleistung der Anlage (Bezugsgröße nach § 9 EEG).
|
||||
render: |
|
||||
type: fnn
|
||||
maxdimpower: {{ .maxconsumptionpower }}
|
||||
w4:
|
||||
source: gpio
|
||||
pin: 13
|
||||
function: read
|
||||
maxcurtailpower: {{ .maxproductionpower }}
|
||||
w3:
|
||||
source: gpio
|
||||
pin: 5
|
||||
function: read
|
||||
s1:
|
||||
source: gpio
|
||||
pin: 17
|
||||
function: read
|
||||
s2:
|
||||
source: gpio
|
||||
pin: 27
|
||||
function: read
|
||||
|
|
@ -23,7 +23,7 @@ test.afterEach(async () => {
|
|||
const CONFIG = "fast.evcc.yaml";
|
||||
|
||||
test.describe("HEMS", () => {
|
||||
test("grid sessions", async ({ page }) => {
|
||||
test("grid sessions in template modal", async ({ page }) => {
|
||||
await start(CONFIG, "hems.sql");
|
||||
await page.goto("/#/config");
|
||||
|
||||
|
|
@ -38,15 +38,16 @@ test.describe("HEMS", () => {
|
|||
const csvLink = hemsModal.getByRole("link", { name: "Download CSV" });
|
||||
await expect(csvLink).toHaveAttribute("href", /\.\/api\/gridsessions\?format=csv&lang=en/);
|
||||
|
||||
await expect(hemsModal.getByTestId("yaml-editor")).toBeVisible();
|
||||
await expect(hemsModal.getByRole("button", { name: "Save" })).toBeVisible();
|
||||
// template selector is shown in create mode, yaml editor hidden until "User-defined" is chosen
|
||||
await expect(hemsModal.getByLabel("Integration")).toBeVisible();
|
||||
await expect(hemsModal.getByTestId("yaml-editor")).not.toBeVisible();
|
||||
await expect(hemsModal).not.toContainText("Configured via evcc.yaml");
|
||||
|
||||
await hemsModal.getByRole("button", { name: "Cancel" }).click();
|
||||
await hemsModal.getByRole("button", { name: "Close" }).click();
|
||||
await expectModalHidden(hemsModal);
|
||||
});
|
||||
|
||||
test("modal yaml-configured", async ({ page }) => {
|
||||
test("modal yaml-configured is locked", async ({ page }) => {
|
||||
await start("hems-yaml.evcc.yaml");
|
||||
await page.goto("/#/config");
|
||||
|
||||
|
|
@ -56,26 +57,28 @@ test.describe("HEMS", () => {
|
|||
|
||||
await expect(hemsModal.getByTestId("grid-sessions")).not.toBeVisible();
|
||||
await expect(hemsModal).toContainText("Configured via evcc.yaml");
|
||||
await expect(hemsModal.getByLabel("Integration")).not.toBeVisible();
|
||||
await expect(hemsModal.getByTestId("yaml-editor")).not.toBeVisible();
|
||||
await expect(hemsModal.getByRole("button", { name: "Save" })).not.toBeVisible();
|
||||
await expect(hemsModal.getByRole("button", { name: "Cancel" })).toBeVisible();
|
||||
|
||||
await hemsModal.getByRole("button", { name: "Cancel" }).click();
|
||||
await hemsModal.getByRole("button", { name: "Close" }).click();
|
||||
await expectModalHidden(hemsModal);
|
||||
});
|
||||
|
||||
test("external control without circuits", async ({ page }) => {
|
||||
test("user-defined relay drives external limit without circuits", async ({ page }) => {
|
||||
const GRID_CONFIG = "hems-grid.evcc.yaml";
|
||||
await startSimulator();
|
||||
await start(GRID_CONFIG);
|
||||
|
||||
await page.goto("/#/config");
|
||||
|
||||
// configure hems
|
||||
// configure hems via user-defined provider
|
||||
await page.getByTestId("hems").getByRole("button", { name: "edit" }).click();
|
||||
const hemsModal = page.getByTestId("hems-modal");
|
||||
await expectModalVisible(hemsModal);
|
||||
await hemsModal.getByLabel("Integration").selectOption({ label: "User-defined integration" });
|
||||
const hemsEditor = hemsModal.getByTestId("yaml-editor");
|
||||
await expect(hemsEditor).toBeVisible();
|
||||
await editorClear(hemsEditor);
|
||||
await editorPaste(
|
||||
hemsEditor,
|
||||
|
|
@ -88,7 +91,7 @@ limit:
|
|||
uri: ${simulatorUrl()}/api/state
|
||||
jq: .hems.relay`
|
||||
);
|
||||
await hemsModal.getByRole("button", { name: "Save" }).click();
|
||||
await hemsModal.getByRole("button", { name: "Validate & save" }).click();
|
||||
await expectModalHidden(hemsModal);
|
||||
|
||||
await restart(GRID_CONFIG);
|
||||
|
|
@ -117,6 +120,53 @@ limit:
|
|||
await stopSimulator();
|
||||
});
|
||||
|
||||
test("change button resets modal and unconfigures the card", async ({ page }) => {
|
||||
await start("hems-grid.evcc.yaml");
|
||||
await page.goto("/#/config");
|
||||
|
||||
// configure hems via user-defined
|
||||
await page.getByTestId("hems").getByRole("button", { name: "edit" }).click();
|
||||
const hemsModal = page.getByTestId("hems-modal");
|
||||
await expectModalVisible(hemsModal);
|
||||
await hemsModal.getByLabel("Integration").selectOption({ label: "User-defined integration" });
|
||||
const hemsEditor = hemsModal.getByTestId("yaml-editor");
|
||||
await editorClear(hemsEditor);
|
||||
await editorPaste(
|
||||
hemsEditor,
|
||||
page,
|
||||
`type: relay
|
||||
maxPower: 4200
|
||||
limit:
|
||||
source: const
|
||||
value: false`
|
||||
);
|
||||
await hemsModal.getByRole("button", { name: "Validate & save" }).click();
|
||||
await expectModalHidden(hemsModal);
|
||||
|
||||
// card shows user-defined label
|
||||
await expect(page.getByTestId("hems")).toContainText("User-defined integration");
|
||||
|
||||
// reopen modal in edit mode
|
||||
await page.getByTestId("hems").getByRole("button", { name: "edit" }).click();
|
||||
await expectModalVisible(hemsModal);
|
||||
await expect(hemsEditor).toBeVisible();
|
||||
await expect(hemsModal.getByLabel("Integration")).toBeDisabled();
|
||||
|
||||
// click pen (change) and confirm
|
||||
page.once("dialog", (d) => d.accept());
|
||||
await hemsModal.getByRole("button", { name: "Change" }).click();
|
||||
|
||||
// provider selector reappears, yaml editor gone
|
||||
await expect(hemsModal.getByLabel("Integration")).toBeEnabled();
|
||||
await expect(hemsEditor).not.toBeVisible();
|
||||
await expect(hemsModal.getByRole("button", { name: "Change" })).not.toBeVisible();
|
||||
|
||||
// close modal, card shows unconfigured
|
||||
await hemsModal.getByRole("button", { name: "Close" }).click();
|
||||
await expectModalHidden(hemsModal);
|
||||
await expect(page.getByTestId("hems")).toContainText(["Configured", "no"].join(""));
|
||||
});
|
||||
|
||||
test("fnn signals", async ({ page }) => {
|
||||
const GRID_CONFIG = "hems-grid.evcc.yaml";
|
||||
await startSimulator();
|
||||
|
|
@ -138,7 +188,9 @@ limit:
|
|||
await page.getByTestId("hems").getByRole("button", { name: "edit" }).click();
|
||||
const hemsModal = page.getByTestId("hems-modal");
|
||||
await expectModalVisible(hemsModal);
|
||||
await hemsModal.getByLabel("Integration").selectOption({ label: "User-defined integration" });
|
||||
const hemsEditor = hemsModal.getByTestId("yaml-editor");
|
||||
await expect(hemsEditor).toBeVisible();
|
||||
await editorClear(hemsEditor);
|
||||
const signalSource = (signal: string) => `
|
||||
source: http
|
||||
|
|
@ -156,7 +208,7 @@ s1:${signalSource("s1")}
|
|||
s2:${signalSource("s2")}
|
||||
w4:${signalSource("w4")}`
|
||||
);
|
||||
await hemsModal.getByRole("button", { name: "Save" }).click();
|
||||
await hemsModal.getByRole("button", { name: "Validate & save" }).click();
|
||||
await expectModalHidden(hemsModal);
|
||||
|
||||
await restart(GRID_CONFIG);
|
||||
|
|
@ -236,7 +288,9 @@ w4:${signalSource("w4")}`
|
|||
await page.getByTestId("hems").getByRole("button", { name: "edit" }).click();
|
||||
const hemsModal = page.getByTestId("hems-modal");
|
||||
await expectModalVisible(hemsModal);
|
||||
await hemsModal.getByLabel("Integration").selectOption({ label: "User-defined integration" });
|
||||
const hemsEditor = hemsModal.getByTestId("yaml-editor");
|
||||
await expect(hemsEditor).toBeVisible();
|
||||
await editorClear(hemsEditor);
|
||||
await editorPaste(
|
||||
hemsEditor,
|
||||
|
|
@ -248,7 +302,7 @@ w3:
|
|||
source: const
|
||||
value: true`
|
||||
);
|
||||
await hemsModal.getByRole("button", { name: "Save" }).click();
|
||||
await hemsModal.getByRole("button", { name: "Validate & save" }).click();
|
||||
await expectModalHidden(hemsModal);
|
||||
|
||||
await restart(GRID_CONFIG);
|
||||
|
|
@ -279,7 +333,7 @@ w3:
|
|||
});
|
||||
});
|
||||
|
||||
test("external control with circuits", async ({ page }) => {
|
||||
test("user-defined relay drives external limit with circuits", async ({ page }) => {
|
||||
const GRID_CONFIG = "hems-grid.evcc.yaml";
|
||||
await start(GRID_CONFIG);
|
||||
|
||||
|
|
@ -295,11 +349,13 @@ w3:
|
|||
await circuitsModal.getByRole("button", { name: "Save" }).click();
|
||||
await expectModalHidden(circuitsModal);
|
||||
|
||||
// configure hems
|
||||
// configure hems via user-defined provider
|
||||
await page.getByTestId("hems").getByRole("button", { name: "edit" }).click();
|
||||
const hemsModal = page.getByTestId("hems-modal");
|
||||
await expectModalVisible(hemsModal);
|
||||
await hemsModal.getByLabel("Integration").selectOption({ label: "User-defined integration" });
|
||||
const hemsEditor = hemsModal.getByTestId("yaml-editor");
|
||||
await expect(hemsEditor).toBeVisible();
|
||||
await editorClear(hemsEditor);
|
||||
await editorPaste(
|
||||
hemsEditor,
|
||||
|
|
@ -311,7 +367,7 @@ limit:
|
|||
source: const
|
||||
value: true`
|
||||
);
|
||||
await hemsModal.getByRole("button", { name: "Save" }).click();
|
||||
await hemsModal.getByRole("button", { name: "Validate & save" }).click();
|
||||
await expectModalHidden(hemsModal);
|
||||
|
||||
await restart(GRID_CONFIG);
|
||||
|
|
|
|||
|
|
@ -108,6 +108,16 @@ func ConfigurationsByClass(class templates.Class) ([]Config, error) {
|
|||
return res, tx.Error
|
||||
}
|
||||
|
||||
// ConfigurationByClass returns the single configuration for class, or nil if none
|
||||
func ConfigurationByClass(class templates.Class) (*Config, error) {
|
||||
configs, err := ConfigurationsByClass(class)
|
||||
if err != nil || len(configs) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &configs[0], nil
|
||||
}
|
||||
|
||||
// ConfigByID returns device by id from the database
|
||||
func ConfigByID(id int) (Config, error) {
|
||||
var config Config
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
evbus "github.com/asaskevich/EventBus"
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/core/loadpoint"
|
||||
"github.com/evcc-io/evcc/hems/hems"
|
||||
)
|
||||
|
||||
var bus = evbus.New()
|
||||
|
|
@ -13,6 +14,7 @@ var instance struct {
|
|||
chargers *handler[api.Charger]
|
||||
vehicles *handler[api.Vehicle]
|
||||
circuits *handler[api.Circuit]
|
||||
hems *handler[hems.API]
|
||||
messengers *handler[api.Messenger]
|
||||
loadpoints *handler[loadpoint.API]
|
||||
tariffs *handler[api.Tariff]
|
||||
|
|
@ -27,6 +29,7 @@ func Reset() {
|
|||
instance.chargers = &handler[api.Charger]{topic: "charger"}
|
||||
instance.vehicles = &handler[api.Vehicle]{topic: "vehicle"}
|
||||
instance.circuits = &handler[api.Circuit]{topic: "circuit"}
|
||||
instance.hems = &handler[hems.API]{topic: "hems"}
|
||||
instance.messengers = &handler[api.Messenger]{topic: "messenger"}
|
||||
instance.loadpoints = &handler[loadpoint.API]{topic: "loadpoint"}
|
||||
instance.tariffs = &handler[api.Tariff]{topic: "tariff"}
|
||||
|
|
@ -60,6 +63,10 @@ func Circuits() Handler[api.Circuit] {
|
|||
return instance.circuits
|
||||
}
|
||||
|
||||
func Hems() Handler[hems.API] {
|
||||
return instance.hems
|
||||
}
|
||||
|
||||
func Loadpoints() Handler[loadpoint.API] {
|
||||
return instance.loadpoints
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,4 +12,5 @@ const (
|
|||
Loadpoint
|
||||
Circuit
|
||||
Messenger
|
||||
Hems
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
const _ClassName = "chargermetervehicletariffloadpointcircuitmessenger"
|
||||
const _ClassName = "chargermetervehicletariffloadpointcircuitmessengerhems"
|
||||
|
||||
var _ClassIndex = [...]uint8{0, 7, 12, 19, 25, 34, 41, 50}
|
||||
var _ClassIndex = [...]uint8{0, 7, 12, 19, 25, 34, 41, 50, 54}
|
||||
|
||||
const _ClassLowerName = "chargermetervehicletariffloadpointcircuitmessenger"
|
||||
const _ClassLowerName = "chargermetervehicletariffloadpointcircuitmessengerhems"
|
||||
|
||||
func (i Class) String() string {
|
||||
i -= 1
|
||||
|
|
@ -32,9 +32,10 @@ func _ClassNoOp() {
|
|||
_ = x[Loadpoint-(5)]
|
||||
_ = x[Circuit-(6)]
|
||||
_ = x[Messenger-(7)]
|
||||
_ = x[Hems-(8)]
|
||||
}
|
||||
|
||||
var _ClassValues = []Class{Charger, Meter, Vehicle, Tariff, Loadpoint, Circuit, Messenger}
|
||||
var _ClassValues = []Class{Charger, Meter, Vehicle, Tariff, Loadpoint, Circuit, Messenger, Hems}
|
||||
|
||||
var _ClassNameToValueMap = map[string]Class{
|
||||
_ClassName[0:7]: Charger,
|
||||
|
|
@ -51,6 +52,8 @@ var _ClassNameToValueMap = map[string]Class{
|
|||
_ClassLowerName[34:41]: Circuit,
|
||||
_ClassName[41:50]: Messenger,
|
||||
_ClassLowerName[41:50]: Messenger,
|
||||
_ClassName[50:54]: Hems,
|
||||
_ClassLowerName[50:54]: Hems,
|
||||
}
|
||||
|
||||
var _ClassNames = []string{
|
||||
|
|
@ -61,6 +64,7 @@ var _ClassNames = []string{
|
|||
_ClassName[25:34],
|
||||
_ClassName[34:41],
|
||||
_ClassName[41:50],
|
||||
_ClassName[50:54],
|
||||
}
|
||||
|
||||
// ClassString retrieves an enum value from the enum constants string name.
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ func init() {
|
|||
|
||||
baseTmpl = template.Must(FuncMap(template.New("base")).ParseFS(includeFS, "includes/*.tpl"))
|
||||
|
||||
for _, class := range []Class{Charger, Meter, Vehicle, Tariff, Messenger, Circuit} {
|
||||
for _, class := range []Class{Charger, Meter, Vehicle, Tariff, Messenger, Circuit, Hems} {
|
||||
load(class)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue