HEMS: refactor handling of production/feedin limits (BC) (#27567)

This commit is contained in:
andig 2026-03-09 12:11:43 +01:00 • committed by GitHub
parent 86e2014532
commit 69db34e768
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 440 additions and 336 deletions

View file

@ -0,0 +1,62 @@
<template>
<template v-for="(node, idx) in nodes" :key="node.name">
<hr v-if="idx > 0 || depth > 0" />
<div :style="style">
<p class="my-2 fw-bold">{{ nodeTitle(node) }}</p>
<DeviceTags :tags="circuitTags(node)" />
</div>
<CircuitTags v-if="node.children?.length" :nodes="node.children" :depth="depth + 1" />
</template>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import DeviceTags from "./DeviceTags.vue";
import type { CircuitNode } from "../../utils/circuits";
import { GRID_CONTROL } from "../../types/evcc";
export default defineComponent({
name: "CircuitTags",
components: { DeviceTags },
props: {
nodes: { type: Array as PropType<CircuitNode[]>, required: true },
depth: { type: Number, default: 0 },
},
computed: {
style(): Record<string, string> | undefined {
return this.depth ? { marginLeft: `${this.depth}rem` } : undefined;
},
},
methods: {
nodeTitle(node: CircuitNode): string {
if (node.name === GRID_CONTROL) return this.$t("config.hems.title");
return node.title || "";
},
circuitTags(node: CircuitNode) {
const result: Record<string, object> = {};
if (node.dimmed) {
result["dimmed"] = { value: true };
}
if (node.curtailed) {
result["curtailed"] = { value: true };
}
const p = node.power || 0;
if (node.maxPower) {
result["powerRange"] = {
value: [p, node.maxPower],
warning: node.power && node.power >= node.maxPower,
};
} else {
result["power"] = { value: p, muted: true };
}
if (node.maxCurrent) {
result["currentRange"] = {
value: [node.current || 0, node.maxCurrent],
warning: node.current && node.current >= node.maxCurrent,
};
}
return result;
},
},
});
</script>

View file

@ -80,7 +80,9 @@ export default {
methods: {
async loadSessions() {
try {
const response = await api.get("gridsessions");
const response = await api.get("gridsessions", {
validateStatus: (code) => [200, 404].includes(code),
});
this.sessions = response.data || [];
} catch (e) {
// Silently fail if no sessions available

View file

@ -7,7 +7,7 @@
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import { type Circuit } from "@/types/evcc";
import { type Circuit, GRID_CONTROL } from "@/types/evcc";
import formatter from "@/mixins/formatter";
export default defineComponent({
@ -18,7 +18,7 @@ export default defineComponent({
},
computed: {
lpcLimit(): number | null {
return this.circuits?.["lpc"]?.maxPower || null;
return this.circuits?.[GRID_CONTROL]?.maxPower || null;
},
},
});

View file

@ -1,6 +1,8 @@
import type { StaticPlan, RepeatingPlan, PlanStrategy } from "../components/ChargingPlans/types";
import type { ForecastSlot, SolarDetails } from "../components/Forecast/types";
export const GRID_CONTROL = "gridcontrol";
// react-native-webview
interface WebView {
postMessage: (message: string) => void;
@ -37,12 +39,6 @@ export interface HemsConfig {
type: string;
}
export interface HemsStatus {
maxPower: number;
}
export type Hems = ConfigStatus<HemsConfig, HemsStatus>;
export interface ShmConfig {
vendorId: string;
deviceId: string;
@ -80,7 +76,7 @@ export interface State {
tariffSolar?: number;
mqtt?: MqttConfig;
influx?: InfluxConfig;
hems?: ConfigStatus<HemsConfig, HemsStatus>;
hems?: ConfigStatus<HemsConfig, unknown>;
shm?: ShmConfig;
sponsor?: ConfigStatus<unknown, SponsorStatus>;
eebus?: ConfigStatus<EebusConfig, EebusStatus>;
@ -138,12 +134,15 @@ export interface Config {
}
export interface Circuit {
name: string;
maxPower: number;
power?: number;
maxCurrent: number;
title?: string;
icon?: string;
parent?: string;
power: number;
current?: number;
config?: Config;
maxPower?: number;
maxCurrent?: number;
dimmed?: boolean;
curtailed?: boolean;
}
export interface Entity {

View file

@ -0,0 +1,51 @@
import { describe, expect, test } from "vitest";
import { circuitTree } from "./circuits";
describe("circuitTree", () => {
test("single root", () => {
const result = circuitTree({
main: { power: 0 },
});
expect(result).toEqual({ name: "main", power: 0 });
});
test("root with children", () => {
const result = circuitTree({
root: { power: 0 },
child1: { power: 0, parent: "root" },
child2: { power: 0, parent: "root" },
});
expect(result).toEqual({
name: "root",
power: 0,
children: [
{ name: "child1", power: 0, parent: "root" },
{ name: "child2", power: 0, parent: "root" },
],
});
});
test("nested two levels", () => {
const result = circuitTree({
root: { power: 0 },
mid: { power: 0, parent: "root" },
leaf: { power: 0, parent: "mid" },
});
expect(result).toEqual({
name: "root",
power: 0,
children: [
{
name: "mid",
power: 0,
parent: "root",
children: [{ name: "leaf", power: 0, parent: "mid" }],
},
],
});
});
test("empty input", () => {
expect(circuitTree({})).toBeNull();
});
});

View file

@ -0,0 +1,28 @@
import type { Circuit } from "../types/evcc";
export interface CircuitNode extends Circuit {
name: string;
children?: CircuitNode[];
}
// circuitTree builds a tree from published circuit data.
// Returns the root node or null if empty.
export function circuitTree(circuits: Record<string, Circuit>): CircuitNode | null {
const nodes = new Map<string, CircuitNode>();
for (const [name, circuit] of Object.entries(circuits)) {
nodes.set(name, { ...circuit, name });
}
let root: CircuitNode | null = null;
for (const [name, circuit] of Object.entries(circuits)) {
if (circuit.parent && nodes.has(circuit.parent)) {
const parent = nodes.get(circuit.parent)!;
parent.children = parent.children || [];
parent.children.push(nodes.get(name)!);
} else {
root = nodes.get(name)!;
}
}
return root;
}

View file

@ -280,28 +280,17 @@
:title="`${$t('config.circuits.title')}`"
editable
:error="hasClassError('circuit')"
:unconfigured="circuitsSorted.length === 0"
:unconfigured="!circuitsRoot"
data-testid="circuits"
@edit="openModal('circuits')"
>
<template #icon><CircuitsIcon /></template>
<template #tags>
<DeviceTags
v-if="circuitsSorted.length == 0"
v-if="!circuitsRoot"
:tags="{ configured: { value: false } }"
/>
<template
v-for="(circuit, idx) in circuitsSorted"
v-else
:key="circuit.name"
>
<hr v-if="Number(idx) > 0" />
<p class="my-2 fw-bold">
{{ circuit.config?.title }}
<code>({{ circuit.name }})</code>
</p>
<DeviceTags :tags="circuitTags(circuit)" />
</template>
<CircuitTags v-else :nodes="[circuitsRoot]" />
</template>
</DeviceCard>
<DeviceCard
@ -439,6 +428,7 @@ import api from "../api";
import ChargerModal from "../components/Config/ChargerModal.vue";
import CircuitsIcon from "../components/MaterialIcon/Circuits.vue";
import CircuitsModal from "../components/Config/CircuitsModal.vue";
import CircuitTags from "../components/Config/CircuitTags.vue";
import collector from "../mixins/collector";
import ControlModal from "../components/Config/ControlModal.vue";
import DeviceCard from "../components/Config/DeviceCard.vue";
@ -483,7 +473,6 @@ import VehicleIcon from "../components/VehicleIcon";
import VehicleModal from "../components/Config/VehicleModal.vue";
import { defineComponent, type PropType } from "vue";
import type {
Circuit,
ConfigCharger,
ConfigVehicle,
ConfigCircuit,
@ -498,7 +487,8 @@ import type {
DeviceType,
Notification,
} from "@/types/evcc";
import { CURRENCY } from "@/types/evcc";
import { CURRENCY, GRID_CONTROL } from "@/types/evcc";
import { circuitTree } from "@/utils/circuits";
type DeviceValuesMap = Record<DeviceType, Record<string, any>>;
@ -521,6 +511,7 @@ export default defineComponent({
ChargerModal,
CircuitsIcon,
CircuitsModal,
CircuitTags,
ControlModal,
DeviceCard,
DeviceTags,
@ -744,9 +735,9 @@ export default defineComponent({
if (["relay", "eebus"].includes(type)) {
result.hemsType = { value: type };
}
const lpc = store.state?.circuits?.["lpc"];
if (lpc) {
const value = lpc.maxPower || null;
const gc = store.state?.circuits?.[GRID_CONTROL];
if (gc) {
const value = gc.maxPower || null;
result.hemsActiveLimit = { value };
}
@ -805,11 +796,8 @@ 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)
);
circuitsRoot() {
return circuitTree(store.state?.circuits || {});
},
tariffsYamlSource() {
return store.state?.tariffs?.yamlSource;
@ -894,9 +882,9 @@ export default defineComponent({
},
async loadCircuits() {
const circuits = (await this.loadConfig("devices/circuit")) || [];
// set lpc default title
// set gridcontrol default title
circuits.forEach((c: ConfigCircuit) => {
if (c.name === "lpc" && !c.config?.title) {
if (c.name === GRID_CONTROL && !c.config?.title) {
c.config = c.config || {};
c.config.title = this.$t("config.hems.title");
}
@ -1086,28 +1074,6 @@ export default defineComponent({
return { ...chargerTags, ...meterTags };
},
openModal,
circuitTags(circuit: ConfigCircuit) {
const circuits = store.state?.circuits || {};
const data =
(circuits[circuit.name] as Circuit | undefined) || ({} as Partial<Circuit>);
const result: Record<string, object> = {};
const p = data.power || 0;
if (data.maxPower) {
result["powerRange"] = {
value: [p, data.maxPower],
warning: data.power && data.power >= data.maxPower,
};
} else {
result["power"] = { value: p, muted: true };
}
if (data.maxCurrent) {
result["currentRange"] = {
value: [data.current || 0, data.maxCurrent],
warning: data.current && data.current >= data.maxCurrent,
};
}
return result;
},
hasDeviceError(type: DeviceType, name?: string) {
if (!name) return false;
const fatals = store.state?.fatal || [];

View file

@ -16,7 +16,6 @@ import (
"github.com/evcc-io/evcc/charger/ocpp"
"github.com/evcc-io/evcc/core"
"github.com/evcc-io/evcc/core/keys"
hemsapi "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"
@ -334,9 +333,8 @@ func runRoot(cmd *cobra.Command, args []string) {
}
// start HEMS server
var hems hemsapi.API
if err == nil {
hems, err = configureHEMS(&conf.HEMS, site)
_, err = configureHEMS(&conf.HEMS, site)
if err != nil {
err = wrapErrorWithClass(ClassHEMS, err)
}
@ -386,7 +384,6 @@ func runRoot(cmd *cobra.Command, args []string) {
valueChan <- util.Param{Key: keys.Hems, Val: globalconfig.ConfigStatus{
Config: conf.HEMS.Redacted(),
Status: hemsapi.GetStatus(hems),
YamlSource: yamlSource.hems,
}}
valueChan <- util.Param{Key: keys.Tariffs, Val: globalconfig.ConfigStatus{

View file

@ -14,6 +14,7 @@ import (
type circuitStruct struct {
Title string `json:"title,omitempty"`
Icon string `json:"icon,omitempty"`
Parent string `json:"parent,omitempty"`
Power float64 `json:"power"`
Current *float64 `json:"current,omitempty"`
MaxPower float64 `json:"maxPower,omitempty"`
@ -27,13 +28,19 @@ func (site *Site) publishCircuits() {
cc := config.Circuits().Devices()
res := make(map[string]circuitStruct, len(cc))
names := make(map[api.Circuit]string, len(cc))
for _, c := range cc {
names[c.Instance()] = c.Config().Name
}
for _, c := range cc {
instance := c.Instance()
props := deviceProperties(c)
data := circuitStruct{
Title: props.Title,
Title: instance.GetTitle(),
Icon: props.Icon,
Parent: names[instance.GetParent()],
Power: instance.GetChargePower(),
MaxPower: instance.GetMaxPower(),
MaxCurrent: instance.GetMaxCurrent(),

View file

@ -7,6 +7,7 @@ import (
"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/hems"
"github.com/evcc-io/evcc/hems/relay"
)
@ -18,6 +19,8 @@ func NewFromConfig(ctx context.Context, typ string, other map[string]any, site s
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-3":
return fnn.NewFromConfig(ctx, other, site)
case "relay":
return relay.NewFromConfig(ctx, other, site)
default:

View file

@ -8,9 +8,7 @@ import (
ucapi "github.com/enbility/eebus-go/usecases/api"
"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/hems/smartgrid"
"github.com/evcc-io/evcc/plugin"
"github.com/evcc-io/evcc/server/eebus"
@ -85,22 +83,12 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*E
return nil, err
}
// get root circuit
root := circuit.Root()
if root == nil {
return nil, errors.New("hems requires load management- please configure root circuit")
}
// register LPC circuit if not already registered
gridcontrol, err := shared.GetOrCreateCircuit("gridcontrol", "eebus")
// setup grid control circuit
gridcontrol, err := smartgrid.SetupCircuit()
if err != nil {
return nil, err
}
// wrap old root with new grid control parent
if err := root.Wrap(gridcontrol); err != nil {
return nil, err
}
site.SetCircuit(gridcontrol)
return NewEEBus(ctx, cc.Ski, cc.Limits, passthroughS, gridcontrol, cc.Interval)
@ -174,10 +162,6 @@ func NewEEBus(ctx context.Context, ski string, limits Limits, passthrough func(b
return c, nil
}
func (c *EEBus) ConsumptionLimit() float64 {
return c.consumptionLimit.Value
}
func (c *EEBus) Run() {
for range time.Tick(c.interval) {
if err := c.run(); err != nil {
@ -222,6 +206,7 @@ func (c *EEBus) run() error {
if time.Since(c.consumptionLimitActivated) > c.consumptionLimit.Duration {
c.log.DEBUG.Println("consumption limit duration exceeded")
c.setConsumptionLimit(0)
c.consumptionLimit.IsActive = false
}
}
@ -229,12 +214,13 @@ func (c *EEBus) run() error {
if c.productionLimitActivated.IsZero() {
if c.productionLimit.IsActive {
c.log.WARN.Println("activating production limit")
c.setProductionLimit(c.productionLimit.Value)
c.setProductionLimit(c.productionLimit.Value, true)
}
} else {
if time.Since(c.productionLimitActivated) > c.productionLimit.Duration {
c.log.DEBUG.Println("production limit duration exceeded")
c.setProductionLimit(0)
c.setProductionLimit(0, false)
c.productionLimit.IsActive = false
}
}
@ -246,7 +232,7 @@ func (c *EEBus) setStatusAndLimit(status status, consumption, production float64
c.statusUpdated = time.Now()
c.setConsumptionLimit(consumption)
c.setProductionLimit(production)
c.setProductionLimit(production, true)
}
func (c *EEBus) setConsumptionLimit(limit float64) {
@ -261,8 +247,8 @@ func (c *EEBus) setConsumptionLimit(limit float64) {
c.root.Dim(active)
c.root.SetMaxPower(limit)
if err := c.updateSession(&c.smartgridConsumptionId, smartgrid.Dim, limit); err != nil {
c.log.ERROR.Printf("smartgrid dim session: %v", err)
if err := smartgrid.UpdateSession(&c.smartgridConsumptionId, smartgrid.Dim, c.root.GetChargePower(), limit, active); err != nil {
c.log.ERROR.Printf("smartgrid session: %v", err)
}
if c.passthrough != nil {
@ -272,9 +258,7 @@ func (c *EEBus) setConsumptionLimit(limit float64) {
}
}
func (c *EEBus) setProductionLimit(limit float64) {
active := limit > 0
func (c *EEBus) setProductionLimit(limit float64, active bool) {
if active {
c.productionLimitActivated = time.Now()
} else {
@ -285,36 +269,7 @@ func (c *EEBus) setProductionLimit(limit float64) {
// TODO make ProductionNominalMax configurable (Site kWp)
// c.root.SetMaxProduction(limit)
if err := c.updateSession(&c.smartgridProductionId, smartgrid.Curtail, limit); err != nil {
c.log.ERROR.Printf("smartgrid curtail session: %v", err)
if err := smartgrid.UpdateSession(&c.smartgridProductionId, smartgrid.Curtail, c.root.GetChargePower(), limit, active); err != nil {
c.log.ERROR.Printf("smartgrid session: %v", err)
}
}
// TODO keep in sync across HEMS implementations
func (c *EEBus) updateSession(id *uint, typ smartgrid.Type, limit float64) error {
// start session
if limit > 0 && *id == 0 {
var power *float64
if p := c.root.GetChargePower(); p > 0 {
power = new(p)
}
sid, err := smartgrid.StartManage(typ, power, limit)
if err != nil {
return err
}
*id = sid
}
// stop session
if limit == 0 && *id != 0 {
if err := smartgrid.StopManage(*id); err != nil {
return err
}
*id = 0
}
return nil
}

View file

@ -2,32 +2,36 @@ package fnn
import (
"context"
"errors"
"sync"
"time"
"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/hems/smartgrid"
"github.com/evcc-io/evcc/plugin"
"github.com/evcc-io/evcc/util"
)
type Fnn3 struct {
log *util.Logger
mu sync.Mutex
log *util.Logger
root api.Circuit
s1, s2, w3 func() (bool, error)
maxPower float64
interval time.Duration
smartgridID uint
limit *float64
maxPower float64
interval time.Duration
}
// NewFromConfig creates an Fnn3 HEMS from generic config
func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*Fnn3, error) {
cc := struct {
MaxPower float64
S1 plugin.Config
S2 plugin.Config
W3 plugin.Config
S1 *plugin.Config
S2 *plugin.Config
Interval time.Duration
}{
Interval: 10 * time.Second,
@ -37,23 +41,13 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*F
return nil, err
}
// get root circuit
root := circuit.Root()
if root == nil {
return nil, errors.New("hems requires load management- please configure root circuit")
}
// register LPP circuit if not already registered
lpp, err := shared.GetOrCreateCircuit("lpp", "fnn-3")
// setup grid control circuit
gridcontrol, err := smartgrid.SetupCircuit()
if err != nil {
return nil, err
}
// wrap old root with new pc parent
if err := root.Wrap(lpp); err != nil {
return nil, err
}
site.SetCircuit(lpp)
site.SetCircuit(gridcontrol)
// s1 getter
s1G, err := cc.S1.BoolGetter(ctx)
@ -71,7 +65,7 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*F
return nil, err
}
return NewFnn3(lpp, s1G, s2G, w3G, cc.MaxPower, cc.Interval)
return NewFnn3(gridcontrol, s1G, s2G, w3G, cc.MaxPower, cc.Interval)
}
// NewFnn3 creates Fnn3 HEMS
@ -108,24 +102,28 @@ func (c *Fnn3) run() error {
return c.curtail(0.0)
}
s2, err := c.s2()
if err != nil {
return err
if c.s2 != nil {
s2, err := c.s2()
if err != nil {
return err
}
if s2 {
// 30%
return c.curtail(0.3)
}
}
if s2 {
// 30%
return c.curtail(0.3)
}
if c.s1 != nil {
s1, err := c.s1()
if err != nil {
return err
}
s1, err := c.s1()
if err != nil {
return err
}
if s1 {
// 60%
return c.curtail(0.6)
if s1 {
// 60%
return c.curtail(0.6)
}
}
// 100%
@ -133,8 +131,23 @@ func (c *Fnn3) run() error {
}
func (c *Fnn3) curtail(frac float64) error {
c.root.Curtail(frac < 1.0)
c.mu.Lock()
defer c.mu.Unlock()
active := frac < 1.0
c.limit = nil
if active {
c.limit = new(c.maxPower * frac)
}
c.root.Curtail(active)
// TODO make ProductionNominalMax configurable (Site kWp)
// c.root.SetMaxPower(c.maxPower*frac)
if err := smartgrid.UpdateSession(&c.smartgridID, smartgrid.Curtail, c.root.GetChargePower(), c.maxPower*frac, active); err != nil {
c.log.ERROR.Printf("smartgrid session: %v", err)
}
return nil
}

View file

@ -3,19 +3,4 @@ package hems
// API describes the HEMS system interface
type API interface {
Run()
ConsumptionLimit() float64
}
type Status struct {
ConsumptionLimit float64 `json:"consumptionLimit"`
// FeedinLimit float64 `json:"feedinLimit,omitempty"`
}
func GetStatus(api API) *Status {
if api == nil {
return nil
}
return &Status{
ConsumptionLimit: api.ConsumptionLimit(),
}
}

View file

@ -2,27 +2,27 @@ package relay
import (
"context"
"errors"
"fmt"
"sync"
"time"
"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/hems/smartgrid"
"github.com/evcc-io/evcc/plugin"
"github.com/evcc-io/evcc/util"
)
type Relay struct {
mu sync.Mutex
log *util.Logger
root api.Circuit
w1 func() (bool, error)
passthrough func(bool) error
smartgridID uint
limit func() (bool, error)
limit *float64
maxPower float64
interval time.Duration
}
@ -42,23 +42,13 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*R
return nil, err
}
// get root circuit
root := circuit.Root()
if root == nil {
return nil, errors.New("hems requires load management- please configure root circuit")
}
// register LPC circuit if not already registered
lpc, err := shared.GetOrCreateCircuit("lpc", "relay")
// setup grid control circuit
gridcontrol, err := smartgrid.SetupCircuit()
if err != nil {
return nil, err
}
// wrap old root with new pc parent
if err := root.Wrap(lpc); err != nil {
return nil, err
}
site.SetCircuit(lpc)
site.SetCircuit(gridcontrol)
// limit getter
limitG, err := cc.Limit.BoolGetter(ctx)
@ -71,27 +61,23 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*R
return nil, err
}
return NewRelay(lpc, limitG, passthroughS, cc.MaxPower, cc.Interval)
return NewRelay(gridcontrol, limitG, passthroughS, cc.MaxPower, cc.Interval)
}
// NewRelay creates Relay HEMS
func NewRelay(root api.Circuit, limit func() (bool, error), passthrough func(bool) error, maxPower float64, interval time.Duration) (*Relay, error) {
func NewRelay(root api.Circuit, w1 func() (bool, error), passthrough func(bool) error, maxPower float64, interval time.Duration) (*Relay, error) {
c := &Relay{
log: util.NewLogger("relay"),
root: root,
passthrough: passthrough,
maxPower: maxPower,
limit: limit,
w1: w1,
interval: interval,
}
return c, nil
}
func (c *Relay) ConsumptionLimit() float64 {
return c.maxPower
}
func (c *Relay) Run() {
for range time.Tick(c.interval) {
if err := c.run(); err != nil {
@ -101,13 +87,13 @@ func (c *Relay) Run() {
}
func (c *Relay) run() error {
limited, err := c.limit()
active, err := c.w1()
if err != nil {
return err
}
var limit float64
if limited {
if active {
limit = c.maxPower
}
@ -115,43 +101,22 @@ func (c *Relay) run() error {
return err
}
if err := c.updateSession(limit); err != nil {
if err := smartgrid.UpdateSession(&c.smartgridID, smartgrid.Dim, c.root.GetChargePower(), limit, active); err != nil {
return fmt.Errorf("smartgrid session: %v", err)
}
return nil
}
// TODO keep in sync across HEMS implementations
func (c *Relay) updateSession(limit float64) error {
// start session
if limit > 0 && c.smartgridID == 0 {
var power *float64
if p := c.root.GetChargePower(); p > 0 {
power = new(p)
}
sid, err := smartgrid.StartManage(smartgrid.Dim, power, limit)
if err != nil {
return err
}
c.smartgridID = sid
}
// stop session
if limit == 0 && c.smartgridID != 0 {
if err := smartgrid.StopManage(c.smartgridID); err != nil {
return err
}
c.smartgridID = 0
}
return nil
}
func (c *Relay) setLimited(limit float64) error {
c.mu.Lock()
defer c.mu.Unlock()
c.limit = nil
if limit > 0 {
c.limit = new(limit)
}
c.root.Dim(limit > 0)
c.root.SetMaxPower(limit)

View file

@ -1,30 +0,0 @@
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
}

42
hems/smartgrid/circuit.go Normal file
View file

@ -0,0 +1,42 @@
package smartgrid
import (
"errors"
"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"
)
const GridControl = "gridcontrol"
// SetupCircuit returns or registers the grid control circuit
func SetupCircuit() (api.Circuit, error) {
if _, err := config.Circuits().ByName(GridControl); err == nil {
return nil, errors.New("gridcontrol is a reserved name and will be auto-created as root circuit when hems is configured")
}
root := circuit.Root()
// create new circuit
circuit, err := circuit.New(util.NewLogger(GridControl), "", 0, 0, nil, time.Minute)
if err != nil {
return nil, err
}
dev := config.NewStaticDevice[api.Circuit](config.Named{Name: GridControl}, circuit)
if err := config.Circuits().Add(dev); err != nil {
return nil, err
}
// wrap old root with new grid control parent
if root != nil {
if err := root.Wrap(circuit); err != nil {
return nil, err
}
}
return circuit, nil
}

View file

@ -13,6 +13,34 @@ func init() {
})
}
func UpdateSession(id *uint, typ Type, circuitPower, limit float64, active bool) error {
// start session
if active && *id == 0 {
var power *float64
if circuitPower > 0 {
power = new(circuitPower)
}
sid, err := StartManage(typ, power, limit)
if err != nil {
return err
}
*id = sid
}
// stop session
if !active && *id != 0 {
if err := StopManage(*id); err != nil {
return err
}
*id = 0
}
return nil
}
func StartManage(typ Type, grid *float64, limit float64) (uint, error) {
gs := GridSession{
Created: time.Now(),

View file

@ -21,7 +21,7 @@ type Type string
const (
Dim Type = "consumption"
Curtail Type = "feedin"
Curtail Type = "production"
)
type GridSessions []GridSession

View file

@ -130,8 +130,9 @@
"currency": "Währung",
"current": "Strom",
"currentRange": "Strom",
"curtailed": "Einspeisung begrenzt",
"detected": "Erkannt",
"dimmed": "Gedimmt",
"dimmed": "Verbrauch begrenzt",
"enabled": "Ladebereit",
"energy": "Energie",
"events": "Ereignisse",

View file

@ -130,8 +130,9 @@
"currency": "Currency",
"current": "Current",
"currentRange": "Current",
"curtailed": "Feed-in limited",
"detected": "Detected",
"dimmed": "Dimmed",
"dimmed": "Consumption limited",
"enabled": "Enabled",
"energy": "Energy",
"events": "Events",

View file

@ -26,9 +26,8 @@ test.describe("circuit", async () => {
);
await expect(page.getByTestId("circuits")).toHaveCount(1);
await expect(page.getByTestId("circuits")).toContainText(["Power", "2.1 kW"].join(""));
await expect(page.getByTestId("circuits")).toContainText(
["Current", "3.0 A / 16.0 A"].join("")
["Main", "Power", "2.1 kW", "Current", "3.0 A / 16.0 A"].join("")
);
});
@ -88,11 +87,11 @@ test.describe("circuit", async () => {
`- name: main
meter: db:1
maxcurrent: 16
- name: house
- name: circuit_house
title: House
maxcurrent: 10
parent: main
- name: garage
- name: circuit_garage
title: Garage
maxcurrent: 8
parent: main`
@ -112,8 +111,8 @@ test.describe("circuit", async () => {
// assign loadpoint to circuit
await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click();
await expectModalVisible(lpModal);
await lpModal.getByLabel("Circuit").selectOption("Garage [garage]");
await lpModal.getByLabel("Circuit").selectOption("House [house]");
await lpModal.getByLabel("Circuit").selectOption("Garage [circuit_garage]");
await lpModal.getByLabel("Circuit").selectOption("House [circuit_house]");
await lpModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(lpModal);
@ -134,19 +133,19 @@ test.describe("circuit", async () => {
await expect(page.getByTestId("circuits")).toHaveCount(1);
await expect(page.getByTestId("circuits")).toContainText(
["(main)", "Power", "2.1 kW", "Current", "3.0 A / 16.0 A"].join("")
["Main", "Power", "2.1 kW", "Current", "3.0 A / 16.0 A"].join("")
);
await expect(page.getByTestId("circuits")).toContainText(
["House (house)", "Power", "1.0 kW", "Current", "6.0 A / 10.0 A"].join("")
["House", "Power", "1.0 kW", "Current", "6.0 A / 10.0 A"].join("")
);
await expect(page.getByTestId("circuits")).toContainText(
["Garage (garage)", "Power", "0.0 kW", "Current", "0.0 A / 8.0 A"].join("")
["Garage", "Power", "0.0 kW", "Current", "0.0 A / 8.0 A"].join("")
);
// assign to garage
await page.getByTestId("loadpoint").getByRole("button", { name: "edit" }).click();
await expectModalVisible(lpModal);
await lpModal.getByLabel("Circuit").selectOption("Garage [garage]");
await lpModal.getByLabel("Circuit").selectOption("Garage [circuit_garage]");
await lpModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(lpModal);
@ -158,13 +157,13 @@ test.describe("circuit", async () => {
// verify circuits
await expect(page.getByTestId("circuits")).toHaveCount(1);
await expect(page.getByTestId("circuits")).toContainText(
["(main)", "Power", "2.1 kW", "Current", "3.0 A / 16.0 A"].join("")
["Main", "Power", "2.1 kW", "Current", "3.0 A / 16.0 A"].join("")
);
await expect(page.getByTestId("circuits")).toContainText(
["House (house)", "Power", "0.0 kW", "Current", "0.0 A / 10.0 A"].join("")
["House", "Power", "0.0 kW", "Current", "0.0 A / 10.0 A"].join("")
);
await expect(page.getByTestId("circuits")).toContainText(
["Garage (garage)", "Power", "1.0 kW", "Current", "6.0 A / 8.0 A"].join("")
["Garage", "Power", "1.0 kW", "Current", "6.0 A / 8.0 A"].join("")
);
});
});

11
tests/hems-grid.evcc.yaml Normal file
View file

@ -0,0 +1,11 @@
interval: 0.1s
site:
meters:
grid: grid
meters:
- name: grid
type: template
template: demo-meter
power: 0

View file

@ -1,11 +1,18 @@
interval: 0.1s
site:
meters:
grid: grid
meters:
- name: grid
type: template
template: demo-meter
power: 0
hems:
type: relay
maxPower: 4200
limit:
source: const
value: true
circuits:
- name: main

View file

@ -1,26 +1,11 @@
import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
import {
expectModalVisible,
expectModalHidden,
editorClear,
editorPaste,
addDemoCharger,
newLoadpoint,
} from "./utils";
import { expectModalVisible, expectModalHidden, editorClear, editorPaste } 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();
});
@ -69,21 +54,13 @@ test.describe("HEMS", () => {
await expectModalHidden(hemsModal);
});
test("configure loadmanagement and hems via UI, verify logic", async ({ page }) => {
await start(CONFIG);
test("external control without circuits", async ({ page }) => {
const GRID_CONFIG = "hems-grid.evcc.yaml";
await startSimulator();
await start(GRID_CONFIG);
await page.goto("/#/config");
// 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");
@ -104,23 +81,12 @@ limit:
await hemsModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(hemsModal);
await restart(CONFIG);
await restart(GRID_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
// verify external control is the only circuit visible
await expect(page.getByTestId("circuits")).toContainText(
["External Limit (lpc)", "Power", "0.0 kW"].join("")
["External Limit", "Power", "0.0 kW"].join("")
);
// enable hems in simulator
@ -132,14 +98,7 @@ limit:
// verify config ui
await page.goto("/#/config");
await expect(page.getByTestId("circuits")).toContainText(
["External Limit (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."
["External Limit", "Consumption limited", "yes", "Power", "0.0 kW / 4.2 kW"].join("")
);
// disable in simulator
@ -147,8 +106,61 @@ limit:
await hemsRelayCheckbox.uncheck();
await simulatorApply(page);
// verify main ui
await page.goto("/");
await expect(hemsWarning).not.toBeVisible();
await stopSimulator();
});
test("external control with circuits", async ({ page }) => {
const GRID_CONFIG = "hems-grid.evcc.yaml";
await start(GRID_CONFIG);
await page.goto("/#/config");
// 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: main\n title: House`);
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: const
value: true`
);
await hemsModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(hemsModal);
await restart(GRID_CONFIG);
await page.goto("/#/config");
// verify external control is the top-most circuit, with main beneath it
await expect(page.getByTestId("circuits")).toContainText(
[
"External Limit",
"Consumption limited",
"yes",
"Power",
"0.0 kW / 4.2 kW",
"House",
"Consumption limited",
"yes",
"Power",
"0.0 kW",
].join("")
);
});
});