diff --git a/assets/js/components/Config/HemsModal.vue b/assets/js/components/Config/HemsModal.vue index 18e1a6e42..8fcf8cc5d 100644 --- a/assets/js/components/Config/HemsModal.vue +++ b/assets/js/components/Config/HemsModal.vue @@ -71,11 +71,15 @@ 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 relayHemsYaml from "./defaultYaml/relayHems.yaml?raw"; import api from "../../api"; import { docsPrefix } from "@/i18n"; import DownloadButton from "../Helper/DownloadButton.vue"; import formatter from "../../mixins/formatter"; +// selector value for the relay variant; both variants save as type custom +const RELAY_OPTION = "relay"; + const initialValues = { type: ConfigType.Template, icon: undefined, @@ -147,7 +151,10 @@ export default defineComponent({ return [ { label: "generic", - options: [customTemplateOption(this.$t("config.hems.customOption"))], + options: [ + customTemplateOption(this.$t("config.hems.type.custom")), + customTemplateOption(this.$t("config.hems.type.relay"), RELAY_OPTION), + ], }, { label: "integrations", @@ -156,12 +163,12 @@ export default defineComponent({ ]; }, isYamlInputType(type: ConfigType): boolean { - return type === ConfigType.Custom; + return type === ConfigType.Custom || (type as string) === RELAY_OPTION; }, handleTemplateChange(value: string, values: DeviceValues) { - if (value === ConfigType.Custom) { + if (this.isYamlInputType(value as ConfigType)) { values.type = ConfigType.Custom; - values.yaml = customHemsYaml; + values.yaml = value === RELAY_OPTION ? relayHemsYaml : customHemsYaml; } }, onAdded(name: string) { diff --git a/assets/js/components/Config/defaultYaml/customHems.yaml b/assets/js/components/Config/defaultYaml/customHems.yaml index 9c361cfed..9cf19ef31 100644 --- a/assets/js/components/Config/defaultYaml/customHems.yaml +++ b/assets/js/components/Config/defaultYaml/customHems.yaml @@ -1,29 +1,12 @@ -## external limit via relay contact (binary on/off) -type: relay -maxpower: 4200 # total load limit while signal is active (W) -limit: # input signal, plugin +## required attributes +type: custom +maxconsumptionpower: # total load limit (W), 0 = no limit 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 + value: 0 -## 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 } +## optional attributes +#curtailedpercent: # allowed feed-in (0..100), 100 = uncurtailed +# source: const +# value: 100 +#productionnominalmax: 10000 # nominal production power (W), required with curtailedpercent +#interval: 10s # polling interval diff --git a/assets/js/components/Config/defaultYaml/relayHems.yaml b/assets/js/components/Config/defaultYaml/relayHems.yaml new file mode 100644 index 000000000..df52ba6fe --- /dev/null +++ b/assets/js/components/Config/defaultYaml/relayHems.yaml @@ -0,0 +1,32 @@ +## required attributes +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 + + +## optional attributes +#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 } diff --git a/hems/config.go b/hems/config.go index 03c62d64b..70c638407 100644 --- a/hems/config.go +++ b/hems/config.go @@ -7,6 +7,7 @@ import ( "github.com/evcc-io/evcc/core/site" "github.com/evcc-io/evcc/hems/config" + _ "github.com/evcc-io/evcc/hems/custom" _ "github.com/evcc-io/evcc/hems/eebus" _ "github.com/evcc-io/evcc/hems/fnn" "github.com/evcc-io/evcc/hems/hems" diff --git a/hems/custom/custom.go b/hems/custom/custom.go new file mode 100644 index 000000000..7516ca120 --- /dev/null +++ b/hems/custom/custom.go @@ -0,0 +1,242 @@ +package custom + +import ( + "context" + "errors" + "fmt" + "math" + "sync" + "time" + + "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(api.Custom, NewFromConfig) +} + +// Custom implements a plugin-configurable HEMS. +type Custom struct { + mu sync.Mutex + log *util.Logger + + site site.API + publishFunc func() + + maxConsumptionPower func() (float64, error) + curtailedPercent func() (int64, error) + + productionNominalMax float64 + interval time.Duration + + smartgridConsumptionID uint + smartgridProductionID uint + + consumptionLimit *float64 + productionPercent int // allowed feed-in percent (0..100), 100 = uncurtailed +} + +// NewFromConfig creates a custom HEMS from generic config. +func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*Custom, error) { + cc := struct { + MaxConsumptionPower *plugin.Config + CurtailedPercent *plugin.Config + ProductionNominalMax float64 + Interval time.Duration + }{ + Interval: 10 * time.Second, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + maxConsumptionPowerG, err := cc.MaxConsumptionPower.FloatGetter(ctx) + if err != nil { + return nil, fmt.Errorf("max consumption power: %w", err) + } + + curtailedPercentG, err := cc.CurtailedPercent.IntGetter(ctx) + if err != nil { + return nil, fmt.Errorf("curtailed percent: %w", err) + } + + return NewCustom(site, maxConsumptionPowerG, curtailedPercentG, math.Abs(cc.ProductionNominalMax), cc.Interval) +} + +// NewCustom creates a custom HEMS. +func NewCustom(site site.API, maxConsumptionPower func() (float64, error), curtailedPercent func() (int64, error), productionNominalMax float64, interval time.Duration) (*Custom, error) { + if maxConsumptionPower == nil && curtailedPercent == nil { + return nil, errors.New("must have either maxconsumptionpower or curtailedpercent") + } + + if curtailedPercent != nil && productionNominalMax == 0 { + return nil, errors.New("cannot have curtailedpercent without productionnominalmax") + } + + c := &Custom{ + log: util.NewLogger("custom"), + site: site, + maxConsumptionPower: maxConsumptionPower, + curtailedPercent: curtailedPercent, + productionNominalMax: productionNominalMax, + productionPercent: 100, + interval: interval, + } + + // read the plugins once synchronously so limits are valid as soon as NewCustom returns + if err := c.run(); err != nil { + return nil, err + } + + return c, nil +} + +func (c *Custom) SetUpdated(f func()) { + c.mu.Lock() + defer c.mu.Unlock() + c.publishFunc = f +} + +// Run starts the control loop. NewCustom already ran the first pass. +func (c *Custom) Run() { + for range time.Tick(c.interval) { + if err := c.run(); err != nil { + c.log.ERROR.Println(err) + } + + if c.publishFunc != nil { + c.publishFunc() + } + } +} + +func (c *Custom) run() error { + return errors.Join(c.runDim(), c.runCurtail()) +} + +// runDim reads the consumption limit. No-op if not configured. +// The previous limit is retained if reading fails. +func (c *Custom) runDim() error { + if c.maxConsumptionPower == nil { + return nil + } + + limit, err := c.maxConsumptionPower() + if err != nil { + return err + } + + if limit < 0 { + return fmt.Errorf("invalid consumption limit: %.0fW", limit) + } + + c.setConsumptionLimit(limit) + + if err := smartgrid.UpdateSession(&c.smartgridConsumptionID, smartgrid.Dim, c.site.GetGridPower(), limit, limit > 0); err != nil { + return fmt.Errorf("smartgrid session: %v", err) + } + + return nil +} + +// runCurtail reads the curtailment percentage. No-op if not configured. +// The previous percentage is retained if reading fails. +func (c *Custom) runCurtail() error { + if c.curtailedPercent == nil { + return nil + } + + percent, err := c.curtailedPercent() + if err != nil { + return err + } + + if percent < 0 || percent > 100 { + return fmt.Errorf("invalid curtailment percent: %d", percent) + } + + c.setProductionLimit(int(percent)) + + active := percent < 100 + var limit float64 + if active { + limit = float64(percent) / 100 * c.productionNominalMax + } + + if err := smartgrid.UpdateSession(&c.smartgridProductionID, smartgrid.Curtail, c.site.GetGridPower(), limit, active); err != nil { + return fmt.Errorf("smartgrid session: %v", err) + } + + return nil +} + +// setConsumptionLimit applies the dimming limit. +func (c *Custom) setConsumptionLimit(limit float64) { + c.mu.Lock() + defer c.mu.Unlock() + + c.consumptionLimit = nil + if limit > 0 { + c.consumptionLimit = &limit + } +} + +// setProductionLimit applies the curtailment limit. +func (c *Custom) setProductionLimit(percent int) { + c.mu.Lock() + defer c.mu.Unlock() + + c.productionPercent = percent +} + +var _ api.HEMS = (*Custom)(nil) + +// CurtailedPercent implements api.HEMS, returning the allowed production percent. +func (c *Custom) CurtailedPercent() *int { + if c.curtailedPercent == nil { + return nil + } + + c.mu.Lock() + defer c.mu.Unlock() + + return new(c.productionPercent) +} + +// MaxConsumptionPower implements api.HEMS, returning the active wattage cap. +func (c *Custom) MaxConsumptionPower() *float64 { + if c.maxConsumptionPower == nil { + return nil + } + + c.mu.Lock() + defer c.mu.Unlock() + + if c.consumptionLimit == nil { + return new(0.0) + } + + return new(*c.consumptionLimit) +} + +// MaxProductionPower implements api.HEMS. +func (c *Custom) MaxProductionPower() *float64 { + if c.curtailedPercent == nil { + return nil + } + + c.mu.Lock() + defer c.mu.Unlock() + + if c.productionPercent >= 100 { + return new(0.0) + } + + return new(float64(c.productionPercent) / 100 * c.productionNominalMax) +} diff --git a/hems/custom/custom_test.go b/hems/custom/custom_test.go new file mode 100644 index 000000000..38bdd4c6e --- /dev/null +++ b/hems/custom/custom_test.go @@ -0,0 +1,78 @@ +package custom + +import ( + "testing" + + "github.com/evcc-io/evcc/hems/hems" + "github.com/stretchr/testify/assert" +) + +// TestConsumptionOnly verifies that a custom HEMS without curtailment plugin +// makes no curtailment statement while dimming to the configured limit. +func TestConsumptionOnly(t *testing.T) { + c := &Custom{ + maxConsumptionPower: func() (float64, error) { return 0, nil }, + productionPercent: 100, + } + + assert.Nil(t, c.CurtailedPercent()) + assert.Nil(t, c.MaxProductionPower()) + assert.Nil(t, hems.Curtailed(c)) + + assert.Equal(t, new(0.0), c.MaxConsumptionPower()) + assert.Equal(t, new(false), hems.Dimmed(c)) + + c.setConsumptionLimit(1e3) + assert.Equal(t, new(1e3), c.MaxConsumptionPower()) + assert.Equal(t, new(true), hems.Dimmed(c)) + + c.setConsumptionLimit(0) + assert.Equal(t, new(0.0), c.MaxConsumptionPower()) + assert.Equal(t, new(false), hems.Dimmed(c)) +} + +// TestCurtailmentOnly verifies that a custom HEMS without consumption plugin +// makes no dimming statement while curtailing production. +func TestCurtailmentOnly(t *testing.T) { + c := &Custom{ + curtailedPercent: func() (int64, error) { return 100, nil }, + productionNominalMax: 1e4, + productionPercent: 100, + } + + assert.Nil(t, c.MaxConsumptionPower()) + assert.Nil(t, hems.Dimmed(c)) + + assert.Equal(t, new(100), c.CurtailedPercent()) + assert.Equal(t, new(0.0), c.MaxProductionPower()) + assert.Equal(t, new(false), hems.Curtailed(c)) + + c.setProductionLimit(60) + assert.Equal(t, new(60), c.CurtailedPercent()) + assert.Equal(t, new(6e3), c.MaxProductionPower()) + assert.Equal(t, new(true), hems.Curtailed(c)) +} + +// TestInvalidPluginValues verifies that out-of-range values are rejected and +// the previous limits retained. +func TestInvalidPluginValues(t *testing.T) { + c := &Custom{ + maxConsumptionPower: func() (float64, error) { return -1, nil }, + curtailedPercent: func() (int64, error) { return 101, nil }, + productionPercent: 100, + } + + assert.Error(t, c.runDim()) + assert.Equal(t, new(0.0), c.MaxConsumptionPower()) + + assert.Error(t, c.runCurtail()) + assert.Equal(t, new(100), c.CurtailedPercent()) +} + +func TestConfigValidation(t *testing.T) { + _, err := NewCustom(nil, nil, nil, 0, 0) + assert.Error(t, err) + + _, err = NewCustom(nil, nil, func() (int64, error) { return 100, nil }, 0, 0) + assert.Error(t, err) +} diff --git a/i18n/de.json b/i18n/de.json index dceb76631..c4c3accde 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -374,7 +374,11 @@ "noEvents": "Es wurden noch keine Ereignisse aufgezeichnet.", "recordedEvents": "Aufgezeichnete Ereignisse", "template": "Integration", - "title": "Externe Begrenzung" + "title": "Externe Begrenzung", + "type": { + "custom": "Benutzerdefinierte Integration", + "relay": "Benutzerdefinierte Integration (Relais)" + } }, "icon": { "change": "ändern", diff --git a/i18n/en.json b/i18n/en.json index 9946e903f..62e2b9088 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -378,7 +378,11 @@ "noEvents": "No events recorded yet.", "recordedEvents": "Recorded events", "template": "Integration", - "title": "External Limit" + "title": "External Limit", + "type": { + "custom": "User-defined integration", + "relay": "User-defined integration (relay)" + } }, "icon": { "change": "change", diff --git a/server/http_config_helper.go b/server/http_config_helper.go index 0da57c72a..3604e858a 100644 --- a/server/http_config_helper.go +++ b/server/http_config_helper.go @@ -448,6 +448,19 @@ func testInstance(ctx context.Context, instance any) map[string]testResult { } }) + wg.Go(func() { + if dev, ok := api.Cap[api.HEMS](instance); ok { + if power := dev.MaxConsumptionPower(); power != nil && *power > 0 { + makeResult("dimLimit", *power, nil) + } + if percent := dev.CurtailedPercent(); percent != nil && *percent < 100 { + if limit := dev.MaxProductionPower(); limit != nil { + makeResult("curtailLimit", *limit, nil) + } + } + } + }) + wg.Go(func() { if dev, ok := api.Cap[api.Identifier](instance); ok { val, err := dev.Identify() diff --git a/tests/hems.spec.ts b/tests/hems.spec.ts index 59e451c89..bb664fa1d 100644 --- a/tests/hems.spec.ts +++ b/tests/hems.spec.ts @@ -38,6 +38,30 @@ test.describe("HEMS", () => { await expect(hemsModal.getByTestId("yaml-editor")).not.toBeVisible(); await expect(hemsModal).not.toContainText("Configured via evcc.yaml"); + // both user-defined variants prefill their own yaml + const editor = hemsModal.getByTestId("yaml-editor"); + await hemsModal + .getByLabel("Integration") + .selectOption({ label: "User-defined integration (relay)" }); + await expect(editor).toContainText("type: relay"); + await hemsModal.getByLabel("Integration").selectOption({ label: "User-defined integration" }); + await expect(editor).toContainText("type: custom"); + + // validate reports active limits + const testResult = hemsModal.getByTestId("test-result"); + await editorClear(editor); + await editorPaste( + editor, + page, + `type: custom +maxconsumptionpower: + source: const + value: 4200` + ); + await testResult.getByRole("link", { name: "validate" }).click(); + await expect(testResult).toContainText("Status: successful"); + await expect(testResult).toContainText(["Consumption limit", "4.2 kW"].join("")); + await hemsModal.getByRole("button", { name: "Close" }).click(); await expectModalHidden(hemsModal); }); @@ -99,7 +123,9 @@ test.describe("HEMS", () => { 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" }); + await hemsModal + .getByLabel("Integration") + .selectOption({ label: "User-defined integration (relay)" }); const hemsEditor = hemsModal.getByTestId("yaml-editor"); await expect(hemsEditor).toBeVisible(); await editorClear(hemsEditor); @@ -151,7 +177,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" }); + await hemsModal + .getByLabel("Integration") + .selectOption({ label: "User-defined integration (relay)" }); const hemsEditor = hemsModal.getByTestId("yaml-editor"); await editorClear(hemsEditor); await editorPaste( @@ -211,7 +239,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" }); + await hemsModal + .getByLabel("Integration") + .selectOption({ label: "User-defined integration (relay)" }); const hemsEditor = hemsModal.getByTestId("yaml-editor"); await expect(hemsEditor).toBeVisible(); await editorClear(hemsEditor); @@ -311,7 +341,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" }); + await hemsModal + .getByLabel("Integration") + .selectOption({ label: "User-defined integration (relay)" }); const hemsEditor = hemsModal.getByTestId("yaml-editor"); await expect(hemsEditor).toBeVisible(); await editorClear(hemsEditor); @@ -376,7 +408,9 @@ w3: 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" }); + await hemsModal + .getByLabel("Integration") + .selectOption({ label: "User-defined integration (relay)" }); const hemsEditor = hemsModal.getByTestId("yaml-editor"); await expect(hemsEditor).toBeVisible(); await editorClear(hemsEditor);