HEMS: add custom implementation (#32187)

This commit is contained in:
andig 2026-07-29 14:41:12 +02:00 • committed by GitHub
parent ce75e49192
commit 4c26af7f5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 436 additions and 38 deletions

View file

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

View file

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

View file

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

View file

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

242
hems/custom/custom.go Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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