HEMS/§14a: visualize limit state (#23361)

This commit is contained in:
Michael Geers 2025-09-03 08:25:31 +02:00 • committed by GitHub
parent 5ff723dab0
commit 0744b68336
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 319 additions and 47 deletions

View file

@ -55,6 +55,7 @@ export default {
switch (name) {
case "power":
case "solarForecast":
case "hemsActiveLimit":
return this.fmtW(value);
case "energy":
case "capacity":
@ -94,6 +95,8 @@ export default {
return value
? this.$t("config.deviceValue.yes")
: this.$t("config.deviceValue.no");
case "hemsType":
return this.$t(`config.deviceValueHemsType.${value}`);
}
return value;
},

View file

@ -3,11 +3,11 @@
id="hemsModal"
:title="$t('config.hems.title')"
:description="$t('config.hems.description')"
docs="/docs/reference/configuration/hems"
docs="/docs/features/14a-enwg-steuve"
:defaultYaml="defaultYaml"
endpoint="/config/hems"
removeKey="hems"
size="md"
data-testid="hems-modal"
@changed="$emit('changed')"
/>
</template>

View file

@ -1,3 +1,18 @@
## external control via binary in put
#type: relay
#maxPower: 4200 # limit loadpoints to 4.2 kW total
#limit: # limit signal, plugin
# source: mqtt
# topic: hems/limit/status # 0/false = normal, 1/true = limit active
## external control via EEBus protocol
#type: eebus # general EEBus setup (cert gen) required
#ski: "1234-5678-90AB-CDEF" # SKI of control box (grid operator)
## SMA Sunny Home Manager 2.0, will change soon https://github.com/evcc-io/evcc/pull/23190
#type: sma
#allowcontrol: false
#vendorid: AAAAAAAA

View file

@ -173,12 +173,7 @@
data-testid="energyflow-entry-home"
/>
<EnergyflowEntry
:name="
// @ts-ignore
$t('main.energyflow.loadpoints', activeLoadpointsCount, {
count: activeLoadpointsCount,
})
"
:name="loadpointsLabel"
icon="vehicle"
:iconProps="{ names: vehicleIcons }"
:power="loadpointsPower"
@ -479,6 +474,12 @@ export default defineComponent({
loadpointsExpanded() {
return settings.energyflowLoadpoints;
},
loadpointsLabel() {
// @ts-expect-error plural
return this.$t("main.energyflow.loadpoints", this.activeLoadpointsCount, {
count: this.activeLoadpointsCount,
});
},
},
watch: {
pvConfigured() {

View file

@ -0,0 +1,25 @@
<template>
<div v-if="lpcLimit" class="alert alert-warning" data-testid="hems-warning">
<strong>{{ $t("main.hemsWarning.title") }}</strong>
{{ $t("main.hemsWarning.description", { limit: fmtW(lpcLimit, POWER_UNIT.KW) }) }}
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import { type Circuit } from "@/types/evcc";
import formatter from "@/mixins/formatter";
export default defineComponent({
name: "HemsWarning",
mixins: [formatter],
props: {
circuits: { type: Object as PropType<Record<string, Circuit>> },
},
computed: {
lpcLimit(): number | null {
return this.circuits?.["lpc"]?.maxPower || null;
},
},
});
</script>

View file

@ -19,6 +19,7 @@
<TopNavigation v-bind="topNavigation" />
</div>
</div>
<HemsWarning :circuits="circuits" />
<Energyflow v-if="loadpoints.length > 0" v-bind="energyflow" />
</div>
<div class="d-flex flex-column justify-content-between content-area">
@ -77,6 +78,7 @@ import "@h2d2/shopicons/es/regular/arrowup";
import Navigation from "../Top/Navigation.vue";
import Notifications from "../Top/Notifications.vue";
import Energyflow from "../Energyflow/Energyflow.vue";
import HemsWarning from "../HemsWarning.vue";
import Loadpoints from "../Loadpoints/Loadpoints.vue";
import Footer from "../Footer/Footer.vue";
import formatter from "@/mixins/formatter";
@ -90,6 +92,7 @@ import type {
Forecast,
LoadpointCompact,
Notification,
Circuit,
SMART_COST_TYPE,
Sponsor,
FatalError,
@ -103,6 +106,7 @@ export default defineComponent({
Loadpoints,
Energyflow,
Footer,
HemsWarning,
Notifications,
TopNavigation: Navigation,
WelcomeIcons,
@ -156,6 +160,7 @@ export default defineComponent({
smartFeedInPriorityAvailable: Boolean,
fatal: { type: Array as PropType<FatalError[]>, default: () => [] },
forecast: Object as PropType<Forecast>,
circuits: Object as PropType<Record<string, Circuit>>,
telemetry: Boolean,
evopt: { type: Object as PropType<EvOpt> },
},

View file

@ -265,11 +265,11 @@
<template #icon><CircuitsIcon /></template>
<template #tags>
<DeviceTags
v-if="circuits.length == 0"
v-if="circuitsSorted.length == 0"
:tags="{ configured: { value: false } }"
/>
<template
v-for="(circuit, idx) in circuits"
v-for="(circuit, idx) in circuitsSorted"
v-else
:key="circuit.name"
>
@ -614,12 +614,21 @@ export default defineComponent({
return this.vehicles.map((v) => ({ key: v.name, name: v.config?.title || v.name }));
},
hemsTags() {
const result = { configured: { value: false }, hemsType: {} };
const { type } = store.state?.hems || {};
if (type) {
result.configured.value = true;
result.hemsType = { value: type };
if (!type) {
return { configured: { value: false } };
}
const result = {
hemsType: {},
hemsActiveLimit: { value: null as number | null },
};
result.hemsType = { value: type };
const lpc = store.state?.circuits?.["lpc"];
if (lpc) {
const value = lpc.maxPower || null;
result.hemsActiveLimit = { value };
}
return result;
},
isSponsor() {
@ -644,6 +653,12 @@ export default defineComponent({
authDisabled: store.state?.authDisabled || false,
};
},
circuitsSorted() {
const sortedNames = Object.keys(store.state?.circuits || {});
return [...this.circuits].sort(
(a, b) => sortedNames.indexOf(a.name) - sortedNames.indexOf(b.name)
);
},
},
watch: {
offline() {
@ -704,7 +719,15 @@ export default defineComponent({
},
async loadCircuits() {
const response = await api.get("/config/devices/circuit");
this.circuits = response.data || [];
const circuits = response.data || [];
// set lpc default title
circuits.forEach((c: ConfigCircuit) => {
if (c.name === "lpc" && !c.config?.title) {
c.config = c.config || {};
c.config.title = this.$t("config.hems.title");
}
});
this.circuits = circuits;
},
async loadSite() {
const response = await api.get("/config/site", {

View file

@ -120,7 +120,7 @@ func New(log *util.Logger, title string, maxCurrent, maxPower float64, meter api
if maxCurrent == 0 {
c.log.DEBUG.Printf("validation of max phase current disabled")
} else if _, ok := meter.(api.PhaseCurrents); meter != nil && !ok {
return nil, fmt.Errorf("meter does not support phase currents")
return nil, errors.New("meter does not support phase currents")
}
return c, nil
@ -150,7 +150,7 @@ func (c *Circuit) setParent(parent api.Circuit) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.parent != nil {
return fmt.Errorf("circuit already has a parent")
return errors.New("circuit already has a parent")
}
c.parent = parent
if parent != nil {
@ -161,6 +161,9 @@ func (c *Circuit) setParent(parent api.Circuit) error {
// Wrap wraps circuit with parent, keeping the original meter
func (c *Circuit) Wrap(parent api.Circuit) error {
if parent == c {
return nil // wrap circuit with itself
}
if c.meter != nil {
parent.(*Circuit).meter = c.meter
}

View file

@ -10,9 +10,9 @@ import (
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/circuit"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/hems/shared"
"github.com/evcc-io/evcc/server/eebus"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
)
type EEBus struct {
@ -32,6 +32,7 @@ type EEBus struct {
failsafeDuration time.Duration
heartbeat *util.Value[struct{}]
interval time.Duration
}
type Limits struct {
@ -44,8 +45,9 @@ type Limits struct {
// New creates an EEBus HEMS from generic config
func New(ctx context.Context, other map[string]interface{}, site site.API) (*EEBus, error) {
cc := struct {
Ski string
Limits `mapstructure:",squash"`
Ski string
Limits `mapstructure:",squash"`
Interval time.Duration
}{
Limits: Limits{
ContractualConsumptionNominalMax: 24800,
@ -53,6 +55,7 @@ func New(ctx context.Context, other map[string]interface{}, site site.API) (*EEB
FailsafeConsumptionActivePowerLimit: 4200,
FailsafeDurationMinimum: 2 * time.Hour,
},
Interval: 10 * time.Second,
}
if err := util.DecodeOther(other, &cc); err != nil {
@ -65,28 +68,23 @@ func New(ctx context.Context, other map[string]interface{}, site site.API) (*EEB
return nil, errors.New("hems requires load management- please configure root circuit")
}
// create new root circuit for LPC
lpc, err := circuit.New(util.NewLogger("lpc"), "eebus", 0, 0, nil, time.Minute)
// register LPC circuit if not already registered
lpc, err := shared.GetOrCreateCircuit("lpc", "eebus")
if err != nil {
return nil, err
}
// register LPC-Circuit for use in config, if not already registered
if _, err := config.Circuits().ByName("lpc"); err != nil {
_ = config.Circuits().Add(config.NewStaticDevice(config.Named{Name: "lpc"}, api.Circuit(lpc)))
}
// wrap old root with new pc parent
if err := root.Wrap(lpc); err != nil {
return nil, err
}
site.SetCircuit(lpc)
return NewEEBus(ctx, cc.Ski, cc.Limits, lpc)
return NewEEBus(ctx, cc.Ski, cc.Limits, lpc, cc.Interval)
}
// NewEEBus creates EEBus charger
func NewEEBus(ctx context.Context, ski string, limits Limits, root api.Circuit) (*EEBus, error) {
func NewEEBus(ctx context.Context, ski string, limits Limits, root api.Circuit, interval time.Duration) (*EEBus, error) {
if eebus.Instance == nil {
return nil, errors.New("eebus not configured")
}
@ -97,6 +95,7 @@ func NewEEBus(ctx context.Context, ski string, limits Limits, root api.Circuit)
uc: eebus.Instance.ControllableSystem(),
Connector: eebus.NewConnector(),
heartbeat: util.NewValue[struct{}](2 * time.Minute), // LPC-031
interval: interval,
consumptionLimit: &ucapi.LoadLimit{
Value: limits.ConsumptionLimit,
@ -149,7 +148,7 @@ func NewEEBus(ctx context.Context, ski string, limits Limits, root api.Circuit)
}
func (c *EEBus) Run() {
for range time.Tick(10 * time.Second) {
for range time.Tick(c.interval) {
if err := c.run(); err != nil {
c.log.ERROR.Println(err)
}

View file

@ -8,9 +8,9 @@ import (
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/circuit"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/hems/shared"
"github.com/evcc-io/evcc/plugin"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
)
type Relay struct {
@ -19,13 +19,17 @@ type Relay struct {
root api.Circuit
limit func() (bool, error)
maxPower float64
interval time.Duration
}
// New creates an Relay HEMS from generic config
func New(ctx context.Context, other map[string]interface{}, site site.API) (*Relay, error) {
var cc struct {
cc := struct {
MaxPower float64
Limit plugin.Config
Interval time.Duration
}{
Interval: 10 * time.Second,
}
if err := util.DecodeOther(other, &cc); err != nil {
@ -38,17 +42,12 @@ func New(ctx context.Context, other map[string]interface{}, site site.API) (*Rel
return nil, errors.New("hems requires load management- please configure root circuit")
}
// create new root circuit for LPC
lpc, err := circuit.New(util.NewLogger("lpc"), "relay", 0, 0, nil, time.Minute)
// register LPC circuit if not already registered
lpc, err := shared.GetOrCreateCircuit("lpc", "relay")
if err != nil {
return nil, err
}
// register LPC-Circuit for use in config, if not already registered
if _, err := config.Circuits().ByName("lpc"); err != nil {
_ = config.Circuits().Add(config.NewStaticDevice(config.Named{Name: "lpc"}, api.Circuit(lpc)))
}
// wrap old root with new pc parent
if err := root.Wrap(lpc); err != nil {
return nil, err
@ -61,23 +60,24 @@ func New(ctx context.Context, other map[string]interface{}, site site.API) (*Rel
return nil, err
}
return NewRelay(lpc, limitG, cc.MaxPower)
return NewRelay(lpc, limitG, cc.MaxPower, cc.Interval)
}
// NewRelay creates Relay HEMS
func NewRelay(root api.Circuit, limit func() (bool, error), maxPower float64) (*Relay, error) {
func NewRelay(root api.Circuit, limit func() (bool, error), maxPower float64, interval time.Duration) (*Relay, error) {
c := &Relay{
log: util.NewLogger("relay"),
root: root,
maxPower: maxPower,
limit: limit,
interval: interval,
}
return c, nil
}
func (c *Relay) Run() {
for range time.Tick(10 * time.Second) {
for range time.Tick(c.interval) {
if err := c.run(); err != nil {
c.log.ERROR.Println(err)
}

30
hems/shared/helper.go Normal file
View file

@ -0,0 +1,30 @@
package shared
import (
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/circuit"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
)
// GetOrCreateCircuit returns or registers a circuit if not already registered
func GetOrCreateCircuit(name, title string) (api.Circuit, error) {
if dev, err := config.Circuits().ByName(name); err == nil {
return dev.Instance(), err
}
// create new circuit
circuit, err := circuit.New(util.NewLogger(name), title, 0, 0, nil, time.Minute)
if err != nil {
return nil, err
}
dev := config.NewStaticDevice[api.Circuit](config.Named{Name: name}, circuit)
if err := config.Circuits().Add(dev); err != nil {
return nil, err
}
return circuit, nil
}

View file

@ -99,7 +99,8 @@
"feedinPrice": "Einspeisevergütung",
"gridPrice": "Netzpreis",
"heaterTempLimit": "Heizungslimit",
"hemsType": "System",
"hemsActiveLimit": "Aktives Limit",
"hemsType": "Kommunikation",
"identifier": "RFID-Kennung",
"no": "Nein",
"odometer": "Kilometerstand",
@ -125,6 +126,10 @@
"B": "B (verbunden)",
"C": "C (lädt)"
},
"deviceValueHemsType": {
"eebus": "via EEBus",
"relay": "via Relais"
},
"devices": {
"auxMeter": "Smarter Verbraucher",
"batteryStorage": "Batterie",
@ -177,7 +182,7 @@
"titleEdit": "Netzzähler bearbeiten"
},
"hems": {
"description": "evcc mit einem anderen Heimenergiemanagementsystem verbinden.",
"description": "Leistungsbegrenzung durch externe Systeme (bspw. Steuerboxen, § 14a EnWG). Setzt Lastmanagementkonfiguration voraus.",
"title": "HEMS"
},
"icon": {
@ -721,6 +726,7 @@
"gridImport": "Netzbezug",
"homePower": "Verbrauch",
"loadpoints": "Ladepunkt | Ladepunkt | {count} Ladepunkte",
"loadpointsLimit": "{limit} Limit",
"noEnergy": "Keine Messwerte",
"pv": "Solaranlage",
"pvExport": "Einspeisung",
@ -733,6 +739,10 @@
"vehicleLimit": "Heizungslimit",
"waitForVehicle": "Bereit. Warte auf Heizung …"
},
"hemsWarning": {
"description": "Reduziere Ladeleistung, um {limit} nicht zu überschreiten.",
"title": "Externe Begrenzung:"
},
"loadpoint": {
"avgPrice": "⌀ Preis",
"charged": "Geladen",

View file

@ -99,7 +99,8 @@
"feedinPrice": "Feed-in price",
"gridPrice": "Grid price",
"heaterTempLimit": "Heater limit",
"hemsType": "System",
"hemsActiveLimit": "Active limit",
"hemsType": "Communication",
"identifier": "RFID-Identifier",
"no": "no",
"odometer": "Odometer",
@ -125,6 +126,10 @@
"B": "B (connected)",
"C": "C (charging)"
},
"deviceValueHemsType": {
"eebus": "via EEBus",
"relay": "via Relay"
},
"devices": {
"auxMeter": "Smart consumer",
"batteryStorage": "Battery storage",
@ -177,7 +182,7 @@
"titleEdit": "Edit Grid Meter"
},
"hems": {
"description": "Connect evcc to another home energy management system.",
"description": "Power limitation by external systems (e.g. EnWG 14a controllers). Requires load management configuration.",
"title": "HEMS"
},
"icon": {
@ -721,6 +726,7 @@
"gridImport": "Grid use",
"homePower": "Consumption",
"loadpoints": "Charger| Charger | {count} chargers",
"loadpointsLimit": "{limit} limit",
"noEnergy": "No meter data",
"pv": "Solar system",
"pvExport": "Grid export",
@ -733,6 +739,10 @@
"vehicleLimit": "Heater limit",
"waitForVehicle": "Ready. Waiting for heater…"
},
"hemsWarning": {
"description": "Reduced charging to not exceed {limit}.",
"title": "External limit:"
},
"loadpoint": {
"avgPrice": "⌀ Price",
"charged": "Charged",

1
tests/fast.evcc.yaml Executable file
View file

@ -0,0 +1 @@
interval: 0.1s

115
tests/hems.spec.ts Normal file
View file

@ -0,0 +1,115 @@
import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
import {
expectModalVisible,
expectModalHidden,
editorClear,
editorPaste,
enableExperimental,
addDemoCharger,
newLoadpoint,
} from "./utils";
import { startSimulator, stopSimulator, simulatorUrl, simulatorApply } from "./simulator";
test.use({ baseURL: baseUrl() });
test.describe.configure({ mode: "parallel" });
test.beforeAll(async () => {
await startSimulator();
});
test.afterAll(async () => {
await stopSimulator();
});
test.afterEach(async () => {
await stop();
});
const CONFIG = "fast.evcc.yaml";
test.describe("HEMS", () => {
test("configure loadmanagement and hems via UI, verify logic", async ({ page }) => {
await start(CONFIG);
await page.goto("/#/config");
await enableExperimental(page);
// configure circuits
await page.getByTestId("circuits").getByRole("button", { name: "edit" }).click();
const circuitsModal = page.getByTestId("circuits-modal");
await expectModalVisible(circuitsModal);
const circuitsEditor = circuitsModal.getByTestId("yaml-editor");
await editorClear(circuitsEditor);
await editorPaste(circuitsEditor, page, `- name: lpc`);
await circuitsModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(circuitsModal);
// configure hems
await page.getByTestId("hems").getByRole("button", { name: "edit" }).click();
const hemsModal = page.getByTestId("hems-modal");
await expectModalVisible(hemsModal);
const hemsEditor = hemsModal.getByTestId("yaml-editor");
await editorClear(hemsEditor);
await editorPaste(
hemsEditor,
page,
`type: relay
maxPower: 4200
interval: 0.1s
limit:
source: http
uri: ${simulatorUrl()}/api/state
jq: .hems.relay`
);
await hemsModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(hemsModal);
await restart(CONFIG);
await page.goto("/#/config");
// configure loadpoint
const lpModal = page.getByTestId("loadpoint-modal");
await newLoadpoint(page, "Carport");
await addDemoCharger(page);
await lpModal.getByLabel("Circuit").selectOption("lpc");
await lpModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(lpModal);
await expect(page.getByTestId("loadpoint")).toContainText("Carport");
await restart(CONFIG);
// verify circuits
await expect(page.getByTestId("circuits")).toContainText(
["HEMS (lpc)", "Power", "0.0 kW"].join("")
);
// enable hems in simulator
await page.goto(simulatorUrl());
const hemsRelayCheckbox = page.getByLabel("Relay Limit");
await hemsRelayCheckbox.check();
await simulatorApply(page);
// verify config ui
await page.goto("/#/config");
await expect(page.getByTestId("circuits")).toContainText(
["HEMS (lpc)", "Power", "0.0 kW / 4.2 kW"].join("")
);
// verify main ui
await page.getByTestId("home-link").click();
const hemsWarning = page.getByTestId("hems-warning");
await expect(hemsWarning).toContainText(
"External limit: Reduced charging to not exceed 4.2 kW."
);
// disable in simulator
await page.goto(simulatorUrl());
await hemsRelayCheckbox.uncheck();
await simulatorApply(page);
// verify main ui
await page.goto("/");
await expect(hemsWarning).not.toBeVisible();
});
});

View file

@ -48,6 +48,7 @@ loadpoints:
charger: charger
meter: lp0meter
mode: pv
circuit: lpc
soc:
estimate: false
vehicle: golf
@ -99,3 +100,15 @@ tariffs:
zones:
- hours: 1-6
price: 0.2
hems:
type: relay
maxPower: 4200
interval: 0.25s
limit:
source: http
uri: http://localhost:7072/api/state
jq: .hems.relay
circuits:
- name: lpc

View file

@ -10,6 +10,7 @@ let state = {
},
loadpoints: [{ power: 0, energy: 0, enabled: false, status: "A" }],
vehicles: [{ soc: 0, range: 0 }],
hems: { relay: false },
};
const loggingMiddleware = (

View file

@ -233,6 +233,23 @@
<div class="text-end">
<a class="link-primary" href="#" @click.prevent="addVehicle"> add vehicle </a>
</div>
<h4 class="my-4">HEMS</h4>
<div class="row">
<label for="hemsRelay" class="col-sm-6 col-form-label">Relay Limit</label>
<div class="col-sm-6 mb-3">
<div class="form-check form-switch">
<input
id="hemsRelay"
v-model="state.hems.relay"
class="form-check-input"
type="checkbox"
role="switch"
/>
<label class="form-check-label" for="hemsRelay"> active </label>
</div>
</div>
</div>
<div class="p-4 text-center fixed-bottom bg-light text-dark bg-opacity-75">
<button type="submit" class="btn btn-primary">Apply changes</button>
</div>
@ -260,6 +277,7 @@ export default defineComponent({
status: string;
}[];
vehicles: { soc: number; range: number }[];
hems: { relay: boolean };
} | null,
};
},

View file

@ -28,7 +28,7 @@ test.describe("vehicle startup error (using failing Tesla API)", async () => {
await expect(page.getByTestId("vehicle-not-reachable-icon")).not.toBeVisible();
});
test("broken vehicle: arrival enabeld", async ({ page }) => {
test("broken vehicle: arrival enabled", async ({ page }) => {
await expect(page.getByTestId("vehicle-name")).toHaveText("Broken Tesla");
await page.getByTestId("charging-plan").getByRole("button", { name: "none" }).click();