HEMS: refactor handling of production/feedin limits (BC) (#27567)
This commit is contained in:
parent
86e2014532
commit
69db34e768
24 changed files with 440 additions and 336 deletions
62
assets/js/components/Config/CircuitTags.vue
Normal file
62
assets/js/components/Config/CircuitTags.vue
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
51
assets/js/utils/circuits.test.ts
Normal file
51
assets/js/utils/circuits.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
28
assets/js/utils/circuits.ts
Normal file
28
assets/js/utils/circuits.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 || [];
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
42
hems/smartgrid/circuit.go
Normal 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
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ type Type string
|
|||
|
||||
const (
|
||||
Dim Type = "consumption"
|
||||
Curtail Type = "feedin"
|
||||
Curtail Type = "production"
|
||||
)
|
||||
|
||||
type GridSessions []GridSession
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
11
tests/hems-grid.evcc.yaml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
interval: 0.1s
|
||||
|
||||
site:
|
||||
meters:
|
||||
grid: grid
|
||||
|
||||
meters:
|
||||
- name: grid
|
||||
type: template
|
||||
template: demo-meter
|
||||
power: 0
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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("")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue