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:
andig 2026-06-27 14:32:13 +02:00 • committed by GitHub
parent 37e96867ab
commit 06f6eb1d26
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 985 additions and 175 deletions

View file

@ -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

View file

@ -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;
},

View file

@ -24,14 +24,16 @@
</optgroup>
</template>
</select>
<div v-else class="d-flex gap-2 align-items-stretch">
<input
v-else
:id="`${deviceType}Template`"
type="text"
:value="productName || $t('config.general.customOption')"
disabled
class="form-control w-100"
class="form-control"
/>
<slot name="action" />
</div>
</FormRow>
</template>

View file

@ -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>

View 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 }

View file

@ -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)

View file

@ -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";

View file

@ -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();

View file

@ -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)
}

View file

@ -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{

View file

@ -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
}
if conf.Type == "" {
return nil, nil
}
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)
}
hems, err := hems.NewFromConfig(context.TODO(), typ, other, site)
instance, err := hems.NewFromConfig(context.TODO(), typ, other, site)
if err != nil {
return nil, fmt.Errorf("failed configuring hems: %w", err)
}
site.SetHEMS(hems)
site.SetHEMS(instance)
go hems.Run()
go instance.Run()
return hems, nil
return instance, 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)
}
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 err
}
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 {
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)}
}
}
// 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}
}
if err != nil {
return nil, err
}
site.SetHEMS(instance)
go instance.Run()
return instance, nil
}
// networkSettings reads/migrates network settings

52
cmd/setup_hems_test.go Normal file
View 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)
}

View file

@ -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
View 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
}

View file

@ -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,

View file

@ -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
View 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]),
}
}

View file

@ -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
View 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)
}

View file

@ -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": {

View file

@ -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": {

View file

@ -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
}

View file

@ -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)},

View file

@ -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())

View file

@ -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

View 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 }}

View 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 }}

View 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

View 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

View file

@ -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);

View file

@ -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

View file

@ -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
}

View file

@ -12,4 +12,5 @@ const (
Loadpoint
Circuit
Messenger
Hems
)

View file

@ -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.

View file

@ -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)
}
}