From 06f6eb1d2619500b79769b99f0560fede5520c6c Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 27 Jun 2026 14:32:13 +0200 Subject: [PATCH] HEMS: add templates (#25832) Co-authored-by: Michael Geers Co-authored-by: premultiply <4681172+premultiply@users.noreply.github.com> --- AGENTS.md | 1 + .../Config/DeviceModal/DeviceModalBase.vue | 35 +++- .../Config/DeviceModal/TemplateSelector.vue | 18 +- assets/js/components/Config/HemsModal.vue | 167 +++++++++++++----- .../Config/defaultYaml/customHems.yaml | 29 +++ .../components/Config/defaultYaml/hems.yaml | 30 ---- assets/js/types/evcc.ts | 15 +- assets/js/views/Config.vue | 57 ++++-- cmd/config_delete.go | 3 + cmd/root.go | 27 +-- cmd/setup.go | 85 +++++++-- cmd/setup_hems_test.go | 52 ++++++ hems/config.go | 24 +-- hems/config/config.go | 35 ++++ hems/eebus/eebus.go | 10 +- hems/fnn/fnn.go | 6 + hems/registry/registry.go | 52 ++++++ hems/relay/relay.go | 5 + hems/template.go | 22 +++ i18n/de.json | 8 +- i18n/en.json | 7 +- meter/eebus.go | 3 +- server/http.go | 8 +- server/http_config_device_handler.go | 58 +++++- templates/definition/embed.go | 2 +- templates/definition/hems/fnn-eebus.yaml | 82 +++++++++ templates/definition/hems/fnn-gpio.yaml | 83 +++++++++ templates/definition/hems/hemspro-eebus.yaml | 72 ++++++++ templates/definition/hems/hemspro-gpio.yaml | 46 +++++ tests/hems.spec.ts | 86 +++++++-- util/config/config.go | 10 ++ util/config/instance.go | 7 + util/templates/class.go | 1 + util/templates/class_enumer.go | 12 +- util/templates/init.go | 2 +- 35 files changed, 985 insertions(+), 175 deletions(-) create mode 100644 assets/js/components/Config/defaultYaml/customHems.yaml delete mode 100644 assets/js/components/Config/defaultYaml/hems.yaml create mode 100644 cmd/setup_hems_test.go create mode 100644 hems/config/config.go create mode 100644 hems/registry/registry.go create mode 100644 hems/template.go create mode 100644 templates/definition/hems/fnn-eebus.yaml create mode 100644 templates/definition/hems/fnn-gpio.yaml create mode 100644 templates/definition/hems/hemspro-eebus.yaml create mode 100644 templates/definition/hems/hemspro-gpio.yaml diff --git a/AGENTS.md b/AGENTS.md index 214cc4402..6a43b6572 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue index 7b8e6e20d..cac396875 100644 --- a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue +++ b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue @@ -11,7 +11,7 @@ @visibilitychange="handleVisibilityChange" >
@@ -30,7 +30,11 @@ :product-name="productName" :groups="computedTemplateOptions" @change="handleTemplateChange" - /> + > + +

{{ $t("config.general.typeDeprecated", { type: values.type }) }} @@ -299,11 +303,18 @@ export default defineComponent({ externalTemplate: String as PropType, // 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; }, diff --git a/assets/js/components/Config/DeviceModal/TemplateSelector.vue b/assets/js/components/Config/DeviceModal/TemplateSelector.vue index e663ca8cd..f75dc376e 100644 --- a/assets/js/components/Config/DeviceModal/TemplateSelector.vue +++ b/assets/js/components/Config/DeviceModal/TemplateSelector.vue @@ -24,14 +24,16 @@ - +

+ + +
diff --git a/assets/js/components/Config/HemsModal.vue b/assets/js/components/Config/HemsModal.vue index d215cafc5..7d748e70a 100644 --- a/assets/js/components/Config/HemsModal.vue +++ b/assets/js/components/Config/HemsModal.vue @@ -1,28 +1,55 @@ + + + + - - - - - + @@ -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>; @@ -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(); diff --git a/cmd/config_delete.go b/cmd/config_delete.go index 3226b40b8..6d5db61d2 100644 --- a/cmd/config_delete.go +++ b/cmd/config_delete.go @@ -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) } diff --git a/cmd/root.go b/cmd/root.go index 1afa88364..9595f946c 100644 --- a/cmd/root.go +++ b/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{ diff --git a/cmd/setup.go b/cmd/setup.go index 852610751..19e23db71 100644 --- a/cmd/setup.go +++ b/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 diff --git a/cmd/setup_hems_test.go b/cmd/setup_hems_test.go new file mode 100644 index 000000000..1c6a49db8 --- /dev/null +++ b/cmd/setup_hems_test.go @@ -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) +} diff --git a/hems/config.go b/hems/config.go index 89119487c..03c62d64b 100644 --- a/hems/config.go +++ b/hems/config.go @@ -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) } } diff --git a/hems/config/config.go b/hems/config/config.go new file mode 100644 index 000000000..c848578a5 --- /dev/null +++ b/hems/config/config.go @@ -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 +} diff --git a/hems/eebus/eebus.go b/hems/eebus/eebus.go index 5a1c43551..79c10fd46 100644 --- a/hems/eebus/eebus.go +++ b/hems/eebus/eebus.go @@ -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, diff --git a/hems/fnn/fnn.go b/hems/fnn/fnn.go index c58b88c48..32bd16780 100644 --- a/hems/fnn/fnn.go +++ b/hems/fnn/fnn.go @@ -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 { diff --git a/hems/registry/registry.go b/hems/registry/registry.go new file mode 100644 index 000000000..613b874f5 --- /dev/null +++ b/hems/registry/registry.go @@ -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]), + } +} diff --git a/hems/relay/relay.go b/hems/relay/relay.go index 9db2dfc93..4fe63bab8 100644 --- a/hems/relay/relay.go +++ b/hems/relay/relay.go @@ -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 diff --git a/hems/template.go b/hems/template.go new file mode 100644 index 000000000..472393d4c --- /dev/null +++ b/hems/template.go @@ -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) +} diff --git a/i18n/de.json b/i18n/de.json index 2bab9c2d9..51728b635 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -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": { diff --git a/i18n/en.json b/i18n/en.json index 58f6fe75b..10d72cb69 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -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": { diff --git a/meter/eebus.go b/meter/eebus.go index c9069b12a..4c9afe145 100644 --- a/meter/eebus.go +++ b/meter/eebus.go @@ -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 } diff --git a/server/http.go b/server/http.go index fbb6faae4..420dee139 100644 --- a/server/http.go +++ b/server/http.go @@ -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)}, diff --git a/server/http_config_device_handler.go b/server/http_config_device_handler.go index b92e85ef1..f370245e0 100644 --- a/server/http_config_device_handler.go +++ b/server/http_config_device_handler.go @@ -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()) diff --git a/templates/definition/embed.go b/templates/definition/embed.go index 4a147796b..3202a14b6 100644 --- a/templates/definition/embed.go +++ b/templates/definition/embed.go @@ -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 diff --git a/templates/definition/hems/fnn-eebus.yaml b/templates/definition/hems/fnn-eebus.yaml new file mode 100644 index 000000000..bb029c351 --- /dev/null +++ b/templates/definition/hems/fnn-eebus.yaml @@ -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 }} diff --git a/templates/definition/hems/fnn-gpio.yaml b/templates/definition/hems/fnn-gpio.yaml new file mode 100644 index 000000000..a92448de3 --- /dev/null +++ b/templates/definition/hems/fnn-gpio.yaml @@ -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 }} diff --git a/templates/definition/hems/hemspro-eebus.yaml b/templates/definition/hems/hemspro-eebus.yaml new file mode 100644 index 000000000..c56fc2775 --- /dev/null +++ b/templates/definition/hems/hemspro-eebus.yaml @@ -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 diff --git a/templates/definition/hems/hemspro-gpio.yaml b/templates/definition/hems/hemspro-gpio.yaml new file mode 100644 index 000000000..93897afda --- /dev/null +++ b/templates/definition/hems/hemspro-gpio.yaml @@ -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 diff --git a/tests/hems.spec.ts b/tests/hems.spec.ts index 30ee2444e..6434a8f90 100644 --- a/tests/hems.spec.ts +++ b/tests/hems.spec.ts @@ -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); diff --git a/util/config/config.go b/util/config/config.go index 6a082163c..879c73eec 100644 --- a/util/config/config.go +++ b/util/config/config.go @@ -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 diff --git a/util/config/instance.go b/util/config/instance.go index 1f2e46c7c..430a3b2d4 100644 --- a/util/config/instance.go +++ b/util/config/instance.go @@ -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 } diff --git a/util/templates/class.go b/util/templates/class.go index 520e7e2e8..11dcfee9c 100644 --- a/util/templates/class.go +++ b/util/templates/class.go @@ -12,4 +12,5 @@ const ( Loadpoint Circuit Messenger + Hems ) diff --git a/util/templates/class_enumer.go b/util/templates/class_enumer.go index 3c26b2288..c957a61f8 100644 --- a/util/templates/class_enumer.go +++ b/util/templates/class_enumer.go @@ -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. diff --git a/util/templates/init.go b/util/templates/init.go index 9fbfa53bb..c3083a7a8 100644 --- a/util/templates/init.go +++ b/util/templates/init.go @@ -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) } }