Refactor dim/curtail handling- split hems and circuit (BC) (#30284)

This commit is contained in:
andig 2026-06-07 13:22:03 +02:00 • committed by GitHub
parent 3758ce1957
commit 8106dc8b76
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
70 changed files with 960 additions and 671 deletions

View file

@ -9,7 +9,7 @@ import (
"golang.org/x/oauth2"
)
//go:generate go tool mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,MeterReturnEnergy,PhaseCurrents,Vehicle,ConnectionTimer,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Dimmer,Tariff
//go:generate go tool mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,MeterReturnEnergy,PhaseCurrents,Vehicle,ConnectionTimer,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Dimmer,HEMS,Tariff
// Meter provides total active power in W
type Meter interface {
@ -273,7 +273,7 @@ type Circuit interface {
SetTitle(string)
GetParent() Circuit
RegisterChild(child Circuit)
Wrap(parent Circuit) error
SetHEMS(HEMS)
HasMeter() bool
GetMaxPower() float64
GetMaxCurrent() float64
@ -282,14 +282,15 @@ type Circuit interface {
Update([]CircuitLoad) error
ValidateCurrent(old, new float64) float64
ValidatePower(old, new float64) float64
}
// EnWG §14a - reduce demand/consumption
Dim(bool)
Dimmed() *bool
// EEG §9 - reduce feed-in to the grid
Curtail(bool)
Curtailed() *bool
// HEMS exposes the runtime state of the home energy management system.
type HEMS interface {
SetUpdated(func())
Dimmed() bool
Curtailed() bool
MaxConsumptionPower() float64 // 0 = no limit
MaxProductionPower() *float64 // nil = no limit
}
// Redactor is an interface to redact sensitive data

View file

@ -1,9 +1,9 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/evcc-io/evcc/api (interfaces: Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,MeterReturnEnergy,PhaseCurrents,Vehicle,ConnectionTimer,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Dimmer,Tariff)
// Source: github.com/evcc-io/evcc/api (interfaces: Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,MeterReturnEnergy,PhaseCurrents,Vehicle,ConnectionTimer,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Dimmer,HEMS,Tariff)
//
// Generated by this command:
//
// mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,MeterReturnEnergy,PhaseCurrents,Vehicle,ConnectionTimer,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Dimmer,Tariff
// mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,MeterReturnEnergy,PhaseCurrents,Vehicle,ConnectionTimer,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Dimmer,HEMS,Tariff
//
// Package api is a generated GoMock package.
@ -895,58 +895,6 @@ func (m *MockCircuit) EXPECT() *MockCircuitMockRecorder {
return m.recorder
}
// Curtail mocks base method.
func (m *MockCircuit) Curtail(arg0 bool) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "Curtail", arg0)
}
// Curtail indicates an expected call of Curtail.
func (mr *MockCircuitMockRecorder) Curtail(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Curtail", reflect.TypeOf((*MockCircuit)(nil).Curtail), arg0)
}
// Curtailed mocks base method.
func (m *MockCircuit) Curtailed() *bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Curtailed")
ret0, _ := ret[0].(*bool)
return ret0
}
// Curtailed indicates an expected call of Curtailed.
func (mr *MockCircuitMockRecorder) Curtailed() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Curtailed", reflect.TypeOf((*MockCircuit)(nil).Curtailed))
}
// Dim mocks base method.
func (m *MockCircuit) Dim(arg0 bool) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "Dim", arg0)
}
// Dim indicates an expected call of Dim.
func (mr *MockCircuitMockRecorder) Dim(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Dim", reflect.TypeOf((*MockCircuit)(nil).Dim), arg0)
}
// Dimmed mocks base method.
func (m *MockCircuit) Dimmed() *bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Dimmed")
ret0, _ := ret[0].(*bool)
return ret0
}
// Dimmed indicates an expected call of Dimmed.
func (mr *MockCircuitMockRecorder) Dimmed() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Dimmed", reflect.TypeOf((*MockCircuit)(nil).Dimmed))
}
// GetChargePower mocks base method.
func (m *MockCircuit) GetChargePower() float64 {
m.ctrl.T.Helper()
@ -1057,6 +1005,18 @@ func (mr *MockCircuitMockRecorder) RegisterChild(child any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterChild", reflect.TypeOf((*MockCircuit)(nil).RegisterChild), child)
}
// SetHEMS mocks base method.
func (m *MockCircuit) SetHEMS(arg0 HEMS) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "SetHEMS", arg0)
}
// SetHEMS indicates an expected call of SetHEMS.
func (mr *MockCircuitMockRecorder) SetHEMS(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetHEMS", reflect.TypeOf((*MockCircuit)(nil).SetHEMS), arg0)
}
// SetMaxCurrent mocks base method.
func (m *MockCircuit) SetMaxCurrent(arg0 float64) {
m.ctrl.T.Helper()
@ -1135,20 +1095,6 @@ func (mr *MockCircuitMockRecorder) ValidatePower(old, new any) *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidatePower", reflect.TypeOf((*MockCircuit)(nil).ValidatePower), old, new)
}
// Wrap mocks base method.
func (m *MockCircuit) Wrap(parent Circuit) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Wrap", parent)
ret0, _ := ret[0].(error)
return ret0
}
// Wrap indicates an expected call of Wrap.
func (mr *MockCircuitMockRecorder) Wrap(parent any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Wrap", reflect.TypeOf((*MockCircuit)(nil).Wrap), parent)
}
// MockDimmer is a mock of Dimmer interface.
type MockDimmer struct {
ctrl *gomock.Controller
@ -1202,6 +1148,98 @@ func (mr *MockDimmerMockRecorder) Dimmed() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Dimmed", reflect.TypeOf((*MockDimmer)(nil).Dimmed))
}
// MockHEMS is a mock of HEMS interface.
type MockHEMS struct {
ctrl *gomock.Controller
recorder *MockHEMSMockRecorder
isgomock struct{}
}
// MockHEMSMockRecorder is the mock recorder for MockHEMS.
type MockHEMSMockRecorder struct {
mock *MockHEMS
}
// NewMockHEMS creates a new mock instance.
func NewMockHEMS(ctrl *gomock.Controller) *MockHEMS {
mock := &MockHEMS{ctrl: ctrl}
mock.recorder = &MockHEMSMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockHEMS) EXPECT() *MockHEMSMockRecorder {
return m.recorder
}
// Curtailed mocks base method.
func (m *MockHEMS) Curtailed() bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Curtailed")
ret0, _ := ret[0].(bool)
return ret0
}
// Curtailed indicates an expected call of Curtailed.
func (mr *MockHEMSMockRecorder) Curtailed() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Curtailed", reflect.TypeOf((*MockHEMS)(nil).Curtailed))
}
// Dimmed mocks base method.
func (m *MockHEMS) Dimmed() bool {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "Dimmed")
ret0, _ := ret[0].(bool)
return ret0
}
// Dimmed indicates an expected call of Dimmed.
func (mr *MockHEMSMockRecorder) Dimmed() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Dimmed", reflect.TypeOf((*MockHEMS)(nil).Dimmed))
}
// MaxConsumptionPower mocks base method.
func (m *MockHEMS) MaxConsumptionPower() float64 {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "MaxConsumptionPower")
ret0, _ := ret[0].(float64)
return ret0
}
// MaxConsumptionPower indicates an expected call of MaxConsumptionPower.
func (mr *MockHEMSMockRecorder) MaxConsumptionPower() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MaxConsumptionPower", reflect.TypeOf((*MockHEMS)(nil).MaxConsumptionPower))
}
// MaxProductionPower mocks base method.
func (m *MockHEMS) MaxProductionPower() *float64 {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "MaxProductionPower")
ret0, _ := ret[0].(*float64)
return ret0
}
// MaxProductionPower indicates an expected call of MaxProductionPower.
func (mr *MockHEMSMockRecorder) MaxProductionPower() *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MaxProductionPower", reflect.TypeOf((*MockHEMS)(nil).MaxProductionPower))
}
// SetUpdated mocks base method.
func (m *MockHEMS) SetUpdated(arg0 func()) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "SetUpdated", arg0)
}
// SetUpdated indicates an expected call of SetUpdated.
func (mr *MockHEMSMockRecorder) SetUpdated(arg0 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetUpdated", reflect.TypeOf((*MockHEMS)(nil).SetUpdated), arg0)
}
// MockTariff is a mock of Tariff interface.
type MockTariff struct {
ctrl *gomock.Controller

View file

@ -2,7 +2,7 @@
<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>
<p class="my-2 fw-bold">{{ node.title }}</p>
<DeviceTags :tags="circuitTags(node)" />
</div>
<CircuitTags v-if="node.children?.length" :nodes="node.children" :depth="depth + 1" />
@ -13,7 +13,6 @@
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",
@ -28,18 +27,8 @@ export default defineComponent({
},
},
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"] = {

View file

@ -27,7 +27,14 @@
/>
</div>
<div v-if="$slots.tags" ref="tagsContainer" :style="tagsStyle">
<hr class="my-3 divide" />
<hr class="my-3 divide" :class="{ 'border-warning': banner }" />
<div
v-if="banner"
class="limit-stripe banner fw-bold text-center text-warning"
data-testid="device-banner"
>
{{ banner }}
</div>
<div ref="tagsContent">
<slot name="tags" />
</div>
@ -52,6 +59,7 @@ export default {
warning: Boolean,
noEditButton: Boolean,
badge: Boolean,
banner: String,
},
emits: ["edit"],
data() {
@ -128,4 +136,31 @@ export default {
margin-left: -1.5rem;
margin-right: -1.5rem;
}
/* bleed edge-to-edge over the card padding, flush below the divider */
.banner {
margin: -1rem -1.5rem 1rem;
padding: 0.5rem 1.5rem;
}
/* animated throttle stripe, echoes the charging bar */
.limit-stripe {
background-color: color-mix(in srgb, var(--evcc-orange) 9%, transparent);
background-image: repeating-linear-gradient(
-45deg,
color-mix(in srgb, var(--evcc-orange) 20%, transparent) 0 8px,
transparent 8px 20px
);
background-size: 28.28px 28.28px;
}
@media (prefers-reduced-motion: no-preference) {
.limit-stripe {
animation: limit-stripe-move 1.5s linear infinite;
}
}
@keyframes limit-stripe-move {
to {
background-position: 28.28px 0;
}
}
</style>

View file

@ -201,7 +201,8 @@ export default {
switch (name) {
case "power":
case "solarForecast":
case "hemsActiveLimit":
case "dimLimit":
case "curtailLimit":
return this.fmtW(value);
case "energy":
case "returnEnergy":
@ -230,18 +231,18 @@ export default {
case "currentRange":
return `${this.fmtNumber(value[0], 1)} A / ${this.fmtNumber(value[1], 1)} A`;
case "controllable":
case "curtailable":
case "phases1p3p":
case "singlePhase":
case "enabled":
case "configured":
case "connected":
case "dimmed":
case "curtailed":
case "loginBlocked":
return value
? this.$t("config.deviceValue.yes")
: this.$t("config.deviceValue.no");
case "hemsType":
return this.$t(`config.deviceValueHemsType.${value}`);
}
return value;
},

View file

@ -5,6 +5,7 @@
:name="meter.name"
:editable="!!meter.id"
:error="hasError"
:banner="banner"
:data-testid="meterType"
@edit="$emit('edit', meterType, meter.id)"
>
@ -51,6 +52,9 @@ export default {
type: Object,
default: () => ({}),
},
banner: {
type: String,
},
},
emits: ["edit"],
computed: {

View file

@ -6,6 +6,24 @@
# source: mqtt
# topic: hems/limit/status # 0/false = normal, 1/true = limit active
## external control via FNN Steuerbox
#type: fnn
#maxDimPower: 4200 # limit consumption to 4.2 kW while dimmed (W4)
#maxCurtailPower: 10000 # installed PV power, base for curtailment steps (W3/S1/S2)
#w4: # dim signal, plugin
# source: mqtt
# topic: hems/w4/status # 0/false = normal, 1/true = limit active
#w3: # curtail to 0% signal, plugin
# source: mqtt
# topic: hems/w3/status
#s2: # curtail to 30% signal, plugin
# source: mqtt
# topic: hems/s2/status
#s1: # curtail to 60% signal, plugin
# source: mqtt
# topic: hems/s1/status
## external control via EEBus
#type: eebus # general EEBus setup (cert gen) required

View file

@ -1,25 +1,72 @@
<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
v-if="consumptionLimit || feedInLimit"
class="limit-stripe small rounded-4 py-2 px-3 mb-3 d-sm-flex flex-wrap align-items-baseline justify-content-between gap-sm-2"
data-testid="hems-warning"
>
<div class="fw-bold text-uppercase text-warning mb-2 mb-sm-0">
{{ $t("main.hemsWarning.title") }}
</div>
<div class="d-flex flex-column gap-1 flex-sm-row gap-sm-4">
<div v-if="consumptionLimit" class="d-flex align-items-baseline gap-2">
<span class="fw-medium">{{ $t("main.hemsWarning.consumption") }}</span>
<span class="fw-bold tabular text-nowrap">&le; {{ consumptionLimit }}</span>
</div>
<div v-if="feedInLimit" class="d-flex align-items-baseline gap-2">
<span class="fw-medium">{{ $t("main.hemsWarning.feedIn") }}</span>
<span class="fw-bold tabular text-nowrap">&le; {{ feedInLimit }}</span>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import { type Circuit, GRID_CONTROL } from "@/types/evcc";
import formatter from "@/mixins/formatter";
import type { HemsStatus } from "@/types/evcc";
import formatter, { POWER_UNIT } from "@/mixins/formatter";
export default defineComponent({
name: "HemsWarning",
mixins: [formatter],
props: {
circuits: { type: Object as PropType<Record<string, Circuit>> },
status: { type: Object as PropType<HemsStatus> },
},
computed: {
lpcLimit(): number | null {
return this.circuits?.[GRID_CONTROL]?.maxPower || null;
consumptionLimit(): string | null {
return this.status?.dimmed && this.status.maxConsumptionPower
? this.fmtW(this.status.maxConsumptionPower, POWER_UNIT.KW)
: null;
},
feedInLimit(): string | null {
return this.status?.curtailed && this.status.maxProductionPower !== undefined
? this.fmtW(this.status.maxProductionPower, POWER_UNIT.KW)
: null;
},
},
});
</script>
<style scoped>
/* animated throttle stripe, echoes the charging bar */
.limit-stripe {
background-color: color-mix(in srgb, var(--evcc-orange) 9%, transparent);
background-image: repeating-linear-gradient(
-45deg,
color-mix(in srgb, var(--evcc-orange) 20%, transparent) 0 8px,
transparent 8px 20px
);
background-size: 28.28px 28.28px;
}
@media (prefers-reduced-motion: no-preference) {
.limit-stripe {
animation: limit-stripe-move 1.5s linear infinite;
}
}
@keyframes limit-stripe-move {
to {
background-position: 28.28px 0;
}
}
</style>

View file

@ -12,7 +12,7 @@
</h1>
<TopNavigationArea :notifications="notifications" />
</div>
<HemsWarning :circuits="circuits" />
<HemsWarning :status="hems?.status" />
<Energyflow v-if="!setupRequired && !hasFatalError" v-bind="energyflow" />
</div>
<div class="d-flex flex-column justify-content-between content-area">
@ -92,7 +92,9 @@ import type {
CURRENCY,
Forecast,
Notification,
Circuit,
ConfigStatus,
HemsConfig,
HemsStatus,
SMART_COST_TYPE,
FatalError,
EvOpt,
@ -152,7 +154,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>>,
hems: Object as PropType<ConfigStatus<HemsConfig, HemsStatus>>,
evopt: { type: Object as PropType<EvOpt> },
},
computed: {

View file

@ -1,8 +1,6 @@
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;
@ -43,6 +41,13 @@ export interface HemsConfig {
type: string;
}
export interface HemsStatus {
dimmed: boolean;
curtailed: boolean;
maxConsumptionPower?: number;
maxProductionPower?: number;
}
export interface ShmConfig {
vendorId: string;
deviceId: string;
@ -96,7 +101,7 @@ export interface State {
tariffSolar?: number;
mqtt?: MqttConfig;
influx?: InfluxConfig;
hems?: ConfigStatus<HemsConfig, unknown>;
hems?: ConfigStatus<HemsConfig, HemsStatus>;
shm?: ShmConfig;
sponsor?: ConfigStatus<unknown, SponsorStatus>;
eebus?: ConfigStatus<EebusConfig, EebusStatus>;
@ -192,8 +197,6 @@ export interface Circuit {
current?: number;
maxPower?: number;
maxCurrent?: number;
dimmed?: boolean;
curtailed?: boolean;
}
export interface Entity {

View file

@ -108,6 +108,7 @@
meter-type="pv"
:has-error="hasDeviceError('meter', meter.name)"
:tags="deviceTags('meter', meter.name)"
:banner="meterBanner(meter.name)"
@edit="(type, id) => openModal('meter', { type, id })"
/>
<MeterCard
@ -283,6 +284,9 @@
editable
:error="hasClassError('circuit')"
:unconfigured="!circuitsRoot"
:banner="
hemsDimmed && circuitsRoot ? $t('config.deviceValue.dimmed') : undefined
"
data-testid="circuits"
@edit="openModal('circuits')"
>
@ -539,8 +543,8 @@ import type {
Notification,
Remote,
} from "@/types/evcc";
import { CURRENCY, GRID_CONTROL } from "@/types/evcc";
import { circuitTree } from "@/utils/circuits";
import { CURRENCY } from "@/types/evcc";
import { circuitTree, type CircuitNode } from "@/utils/circuits";
type DeviceValuesMap = Record<DeviceType, Record<string, any>>;
@ -788,20 +792,29 @@ export default defineComponent({
},
hemsTags(): DeviceTags {
const type = this.hems?.config?.type;
if (!type) {
const status = store.state?.hems?.status;
if (!type && !status) {
return { configured: { value: false } };
}
const result = {
hemsType: {},
hemsActiveLimit: { value: null as number | null },
};
if (["relay", "eebus"].includes(type)) {
result.hemsType = { value: type };
if (!status) {
return { configured: { value: true } };
}
const gc = store.state?.circuits?.[GRID_CONTROL];
if (gc) {
const value = gc.maxPower || null;
result.hemsActiveLimit = { value };
const result: DeviceTags = {};
if (status.dimmed && status.maxConsumptionPower) {
result["dimLimit"] = {
value: status.maxConsumptionPower,
warning: true,
};
} else {
result["dimmed"] = { value: status.dimmed };
}
if (status.curtailed && status.maxProductionPower !== undefined) {
result["curtailLimit"] = {
value: status.maxProductionPower,
warning: true,
};
} else {
result["curtailed"] = { value: status.curtailed };
}
return result;
@ -902,9 +915,13 @@ export default defineComponent({
authDisabled: this.authDisabled,
};
},
circuitsRoot() {
circuitsRoot(): CircuitNode | null {
return circuitTree(store.state?.circuits || {});
},
hemsDimmed(): boolean {
// only consumption limits matter for circuits, curtailment affects feed-in
return !!store.state?.hems?.status?.dimmed;
},
tariffsYamlSource() {
return store.state?.tariffs?.yamlSource;
},
@ -987,9 +1004,7 @@ export default defineComponent({
this.meters = (await this.loadConfig("devices/meter")) || [];
},
async loadCircuits() {
const circuits = (await this.loadConfig("devices/circuit")) || [];
// gridcontrol is auto-created for hems and must not be user-assignable to loadpoints
this.circuits = circuits.filter((c: ConfigCircuit) => c.name !== GRID_CONTROL);
this.circuits = (await this.loadConfig("devices/circuit")) || [];
},
async loadTariffs() {
this.tariffs = (await this.loadConfig("devices/tariff")) || [];
@ -1167,6 +1182,11 @@ export default defineComponent({
deviceTags(type: DeviceType, id: string) {
return this.deviceValues[type][id] || {};
},
meterBanner(name: string): string | undefined {
return this.deviceTags("meter", name)["curtailed"]?.value
? this.$t("config.deviceValue.productionLimited")
: undefined;
},
loadpointTags(loadpoint: ConfigLoadpoint) {
const { charger, meter } = loadpoint;
const chargerTags = charger ? this.deviceTags("charger", charger) : {};

View file

@ -343,9 +343,27 @@ func runRoot(cmd *cobra.Command, args []string) {
// start HEMS server
if err == nil {
_, err = configureHEMS(&conf.HEMS, site)
if err != nil {
err = wrapErrorWithClass(ClassHEMS, err)
if hems, errConf := configureHEMS(&conf.HEMS, site); errConf == nil && hems != nil {
// republish when HEMS state updates
hems.SetUpdated(func() {
valueChan <- util.Param{Key: keys.Hems, Val: globalconfig.ConfigStatus{
Config: conf.HEMS.Redacted(),
YamlSource: yamlSource.hems,
Status: struct {
Dimmed bool `json:"dimmed"`
Curtailed bool `json:"curtailed"`
MaxConsumptionPower float64 `json:"maxConsumptionPower,omitempty"`
MaxProductionPower *float64 `json:"maxProductionPower,omitempty"`
}{
Dimmed: hems.Dimmed(),
Curtailed: hems.Curtailed(),
MaxConsumptionPower: hems.MaxConsumptionPower(),
MaxProductionPower: hems.MaxProductionPower(),
},
}}
})
} else {
err = wrapErrorWithClass(ClassHEMS, errConf)
}
}

View file

@ -802,6 +802,8 @@ func configureHEMS(conf *globalconfig.Hems, site *core.Site) (hemsapi.API, error
return nil, fmt.Errorf("failed configuring hems: %w", err)
}
site.SetHEMS(hems)
go hems.Run()
return hems, nil

View file

@ -34,10 +34,10 @@ type Circuit struct {
getMaxCurrent func() (float64, error) // dynamic max allowed current
getMaxPower func() (float64, error) // dynamic max allowed power
current float64
power float64
dimmed *bool
curtailed *bool
current float64
power float64
hems api.HEMS // only set on the root circuit, supplies the HEMS consumption cap
currentUpdated time.Time
powerUpdated time.Time
@ -175,15 +175,11 @@ func (c *Circuit) setParent(parent api.Circuit) error {
return nil
}
// 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
}
return c.setParent(parent)
// SetHEMS attaches the HEMS instance to the circuit. Valid on root circuit only.
func (c *Circuit) SetHEMS(hems api.HEMS) {
c.mu.Lock()
defer c.mu.Unlock()
c.hems = hems
}
// HasMeter returns the max power setting
@ -193,7 +189,7 @@ func (c *Circuit) HasMeter() bool {
return c.meter != nil
}
// GetMaxPower returns the max power setting
// GetMaxPower returns the configured max power setting.
func (c *Circuit) GetMaxPower() float64 {
if c.getMaxPower != nil {
res, err := c.getMaxPower()
@ -208,6 +204,23 @@ func (c *Circuit) GetMaxPower() float64 {
return c.maxPower
}
// effectiveMaxPower returns the configured max power clamped by the HEMS
// consumption limit. Only the root circuit applies the HEMS clamp; non-root
// callers walk up the parent chain via ValidatePower.
func (c *Circuit) effectiveMaxPower() float64 {
maxPower := c.GetMaxPower()
if c.hems == nil {
return maxPower
}
hemsLimit := c.hems.MaxConsumptionPower()
if hemsLimit <= 0 {
return maxPower
}
return min(hemsLimit, maxPower)
}
// SetMaxPower sets the max power
func (c *Circuit) SetMaxPower(power float64) {
c.mu.Lock()
@ -349,7 +362,7 @@ func (c *Circuit) GetMaxPhaseCurrent() float64 {
// ValidatePower validates power request
func (c *Circuit) ValidatePower(old, new float64) float64 {
if maxPower := c.GetMaxPower(); maxPower != 0 {
if maxPower := c.effectiveMaxPower(); maxPower != 0 {
delta := max(0, new-old)
potential := maxPower - c.power
@ -390,45 +403,3 @@ func (c *Circuit) ValidateCurrent(old, new float64) float64 {
return c.parent.ValidateCurrent(old, new)
}
func (c *Circuit) Dim(dim bool) {
c.mu.Lock()
defer c.mu.Unlock()
c.dimmed = &dim
}
func (c *Circuit) Dimmed() *bool {
c.mu.RLock()
defer c.mu.RUnlock()
if c.dimmed != nil {
return c.dimmed
}
if c.parent == nil {
return nil
}
return c.parent.Dimmed()
}
func (c *Circuit) Curtail(curtail bool) {
c.mu.Lock()
defer c.mu.Unlock()
c.curtailed = &curtail
}
func (c *Circuit) Curtailed() *bool {
c.mu.RLock()
defer c.mu.RUnlock()
if c.curtailed != nil {
return c.curtailed
}
if c.parent == nil {
return nil
}
return c.parent.Curtailed()
}

View file

@ -140,12 +140,38 @@ func TestCircuitCurrents(t *testing.T) {
}
}
func TestWrapCycleDetection(t *testing.T) {
// TestHEMSConsumptionClamp verifies that ValidatePower clamps against the
// HEMS consumption limit when one is registered on the root circuit.
func TestHEMSConsumptionClamp(t *testing.T) {
log := util.NewLogger("foo")
pc, _ := New(log, "root", 0, 0, nil, 0)
lpc, _ := New(log, "lpc", 0, 0, nil, 0)
for _, tc := range []struct {
name string
maxPower float64
hemsLimit float64
request float64
want float64
}{
{"no hems, no max", 0, 0, 9000, 9000},
{"no hems, max set", 5000, 0, 9000, 5000},
{"hems below max", 5000, 4200, 9000, 4200},
{"hems above max", 5000, 9000, 9000, 5000},
// when the user has no circuit max, the HEMS limit is not enforced
// — strictly preserve user intent (no clamp without an explicit user-side cap).
{"hems but no user max", 0, 4200, 9000, 9000},
} {
t.Run(tc.name, func(t *testing.T) {
c, err := New(log, "root", 0, tc.maxPower, nil, 0)
require.NoError(t, err)
require.NoError(t, lpc.setParent(pc))
require.Error(t, pc.Wrap(lpc))
if tc.hemsLimit > 0 {
ctrl := gomock.NewController(t)
hems := api.NewMockHEMS(ctrl)
hems.EXPECT().MaxConsumptionPower().Return(tc.hemsLimit).AnyTimes()
c.hems = hems
}
assert.Equal(t, tc.want, c.ValidatePower(0, tc.request))
})
}
}

View file

@ -77,20 +77,20 @@ func circuitMaxPower(circuit api.Circuit) float64 {
return circuit.GetMaxPower()
}
// circuitDimmed returns a circuits dim status
func circuitDimmed(circuit api.Circuit) *bool {
if circuit == nil {
// hemsDimmed returns the HEMS dim status, nil-safe
func hemsDimmed(hems api.HEMS) *bool {
if hems == nil {
return nil
}
return circuit.Dimmed()
return new(hems.Dimmed())
}
// circuitCurtailed returns a circuit's curtail status
func circuitCurtailed(circuit api.Circuit) *bool {
if circuit == nil {
// hemsCurtailed returns the HEMS curtail status, nil-safe
func hemsCurtailed(hems api.HEMS) *bool {
if hems == nil {
return nil
}
return circuit.Curtailed()
return new(hems.Curtailed())
}

View file

@ -1939,7 +1939,7 @@ func (lp *Loadpoint) phaseSwitchCompleted() bool {
}
// Update is the main control function. It reevaluates meters and charger state
func (lp *Loadpoint) Update(sitePower, batteryBoostPower float64, consumption, feedin api.Rates, batteryBuffered, batteryStart bool, greenShare float64, effPrice, effCo2 *float64) {
func (lp *Loadpoint) Update(sitePower, batteryBoostPower float64, consumption, feedin api.Rates, batteryBuffered, batteryStart bool, greenShare float64, effPrice, effCo2 *float64, dim *bool) {
// auto-disable battery boost when SOC drops below limit
if lp.GetBatteryBoost() != boostDisabled {
if limit := lp.GetBatteryBoostLimit(); limit < 100 {
@ -1987,7 +1987,7 @@ func (lp *Loadpoint) Update(sitePower, batteryBoostPower float64, consumption, f
return
}
if dim := circuitDimmed(lp.circuit); dim != nil {
if dim != nil {
if *dim != dimmed {
if err := dimmer.Dim(*dim); err != nil {
lp.log.ERROR.Printf("dim: %v", err)

View file

@ -689,10 +689,10 @@ func TestUpdatePhaseSwitchNotAvailable(t *testing.T) {
attachListeners(t, lp)
// first cycle attempts the switch, gets api.ErrNotAvailable, adopts 3p
lp.Update(0, 0, nil, nil, false, false, 0, nil, nil)
lp.Update(0, 0, nil, nil, false, false, 0, nil, nil, nil)
require.Equal(t, 3, lp.GetPhases(), "configured phases should be adopted")
// second cycle must not attempt the switch again (Phases1p3p .Times(1))
lp.Update(0, 0, nil, nil, false, false, 0, nil, nil)
lp.Update(0, 0, nil, nil, false, false, 0, nil, nil, nil)
require.Equal(t, 3, lp.GetPhases())
}

View file

@ -183,7 +183,7 @@ func TestUpdatePowerZero(t *testing.T) {
}
lp.mode = tc.mode
lp.Update(0, 0, nil, nil, false, false, 0, nil, nil) // false,sitePower false,0
lp.Update(0, 0, nil, nil, false, false, 0, nil, nil, nil) // false,sitePower false,0
ctrl.Finish()
}
@ -428,7 +428,7 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
charger.EXPECT().Status().Return(api.StatusC, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().MaxCurrent(int64(maxA)).Return(nil)
lp.Update(500, 0, nil, nil, false, false, 0, nil, nil)
lp.Update(500, 0, nil, nil, false, false, 0, nil, nil, nil)
ctrl.Finish()
t.Log("charging above target - soc deactivates charger")
@ -437,7 +437,7 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
charger.EXPECT().Status().Return(api.StatusC, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Enable(false).Return(nil)
lp.Update(500, 0, nil, nil, false, false, 0, nil, nil)
lp.Update(500, 0, nil, nil, false, false, 0, nil, nil, nil)
ctrl.Finish()
t.Log("deactivated charger changes status to B")
@ -445,14 +445,14 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
vehicle.EXPECT().Soc().Return(95.0, nil)
charger.EXPECT().Status().Return(api.StatusB, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
lp.Update(-500, 0, nil, nil, false, false, 0, nil, nil)
lp.Update(-500, 0, nil, nil, false, false, 0, nil, nil, nil)
ctrl.Finish()
t.Log("soc has risen below target - soc update prevented by timer")
clock.Add(5 * time.Minute)
charger.EXPECT().Status().Return(api.StatusB, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
lp.Update(-500, 0, nil, nil, false, false, 0, nil, nil)
lp.Update(-500, 0, nil, nil, false, false, 0, nil, nil, nil)
ctrl.Finish()
t.Log("soc has fallen below target - soc update timer expired")
@ -462,7 +462,7 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().MaxCurrent(int64(maxA)).Return(nil)
charger.EXPECT().Enable(true).Return(nil)
lp.Update(-500, 0, nil, nil, false, false, 0, nil, nil)
lp.Update(-500, 0, nil, nil, false, false, 0, nil, nil, nil)
ctrl.Finish()
}
@ -497,14 +497,14 @@ func TestSetModeAndSocAtDisconnect(t *testing.T) {
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusC, nil)
charger.EXPECT().MaxCurrent(int64(maxA)).Return(nil)
lp.Update(500, 0, nil, nil, false, false, 0, nil, nil)
lp.Update(500, 0, nil, nil, false, false, 0, nil, nil, nil)
t.Log("switch off when disconnected")
clock.Add(5 * time.Minute)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusA, nil)
charger.EXPECT().Enable(false).Return(nil)
lp.Update(-300, 0, nil, nil, false, false, 0, nil, nil)
lp.Update(-300, 0, nil, nil, false, false, 0, nil, nil, nil)
if mode := lp.GetMode(); mode != api.ModeOff {
t.Error("unexpected mode", mode)
@ -566,14 +566,14 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
rater.EXPECT().ChargedEnergy().Return(0.0, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusC, nil)
lp.Update(-1, 0, nil, nil, false, false, 0, nil, nil)
lp.Update(-1, 0, nil, nil, false, false, 0, nil, nil, nil)
t.Log("at 1:00h charging at 5 kWh")
clock.Add(time.Hour)
rater.EXPECT().ChargedEnergy().Return(5.0, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusC, nil)
lp.Update(-1, 0, nil, nil, false, false, 0, nil, nil)
lp.Update(-1, 0, nil, nil, false, false, 0, nil, nil, nil)
expectCache("chargedEnergy", 5000.0)
t.Log("at 1:00h stop charging at 5 kWh")
@ -581,7 +581,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
rater.EXPECT().ChargedEnergy().Return(5.0, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusB, nil)
lp.Update(-1, 0, nil, nil, false, false, 0, nil, nil)
lp.Update(-1, 0, nil, nil, false, false, 0, nil, nil, nil)
expectCache("chargedEnergy", 5000.0)
t.Log("at 1:00h restart charging at 5 kWh")
@ -589,7 +589,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
rater.EXPECT().ChargedEnergy().Return(5.0, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusC, nil)
lp.Update(-1, 0, nil, nil, false, false, 0, nil, nil)
lp.Update(-1, 0, nil, nil, false, false, 0, nil, nil, nil)
expectCache("chargedEnergy", 5000.0)
t.Log("at 1:30h continue charging at 7.5 kWh")
@ -597,7 +597,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
rater.EXPECT().ChargedEnergy().Return(7.5, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusC, nil)
lp.Update(-1, 0, nil, nil, false, false, 0, nil, nil)
lp.Update(-1, 0, nil, nil, false, false, 0, nil, nil, nil)
expectCache("chargedEnergy", 7500.0)
t.Log("at 2:00h stop charging at 10 kWh")
@ -605,7 +605,7 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
rater.EXPECT().ChargedEnergy().Return(10.0, nil)
charger.EXPECT().Enabled().Return(lp.enabled, nil)
charger.EXPECT().Status().Return(api.StatusB, nil)
lp.Update(-1, 0, nil, nil, false, false, 0, nil, nil)
lp.Update(-1, 0, nil, nil, false, false, 0, nil, nil, nil)
expectCache("chargedEnergy", 10000.0)
ctrl.Finish()
@ -805,7 +805,7 @@ func TestConnectionDurationDropDetection(t *testing.T) {
lp.connectedTime = connectedTime
ct.EXPECT().ConnectionDuration().Return(0*time.Second, nil)
lp.Update(500, 0, nil, nil, false, false, 0, nil, nil)
lp.Update(500, 0, nil, nil, false, false, 0, nil, nil, nil)
ctrl.Finish()
assert.NotEqual(t, connectedTime, lp.connectedTime)

View file

@ -408,7 +408,7 @@ func TestReconnectVehicle(t *testing.T) {
// vehicle not updated yet
vehicle.MockChargeState.EXPECT().Status().Return(api.StatusA, nil)
lp.Update(0, 0, nil, nil, false, false, 0, nil, nil)
lp.Update(0, 0, nil, nil, false, false, 0, nil, nil, nil)
ctrl.Finish()
// detection started
@ -422,7 +422,7 @@ func TestReconnectVehicle(t *testing.T) {
// vehicle not updated yet
vehicle.MockChargeState.EXPECT().Status().Return(api.StatusB, nil)
lp.Update(0, 0, nil, nil, false, false, 0, nil, nil)
lp.Update(0, 0, nil, nil, false, false, 0, nil, nil, nil)
ctrl.Finish()
// vehicle detected

View file

@ -45,7 +45,7 @@ const standbyPower = 10 // consider less than 10W as charger in standby
// updater abstracts the Loadpoint implementation for testing
type updater interface {
loadpoint.API
Update(sitePower, batteryBoostPower float64, consumption, feedin api.Rates, batteryBuffered, batteryStart bool, greenShare float64, effectivePrice, effectiveCo2 *float64)
Update(sitePower, batteryBoostPower float64, consumption, feedin api.Rates, batteryBuffered, batteryStart bool, greenShare float64, effectivePrice, effectiveCo2 *float64, dim *bool)
}
var _ site.API = (*Site)(nil)
@ -66,6 +66,7 @@ type Site struct {
// meters
circuit api.Circuit // Circuit
hems api.HEMS // HEMS (set by configureHEMS at boot)
gridMeter api.Meter // Grid usage meter
pvMeters []config.Device[api.Meter] // PV generation meters
batteryMeters []config.Device[api.Meter] // Battery charging meters
@ -938,17 +939,19 @@ func (site *Site) update(lp updater) {
}
site.publishCircuits()
}
if site.hems != nil {
var wg sync.WaitGroup
wg.Go(func() {
if err := site.dimMeters(circuitDimmed(site.circuit)); err != nil {
if err := site.dimMeters(hemsDimmed(site.hems)); err != nil {
site.log.ERROR.Println(err)
}
})
wg.Go(func() {
if err := site.curtailPV(circuitCurtailed(site.circuit)); err != nil {
if err := site.curtailPV(hemsCurtailed(site.hems)); err != nil {
site.log.ERROR.Println(err)
}
})
@ -985,6 +988,7 @@ func (site *Site) update(lp updater) {
lp.Update(
sitePower, max(0, site.battery.Power), consumption, feedin, batteryBuffered, batteryStart,
greenShareLoadpoints, site.effectivePrice(greenShareLoadpoints), site.effectiveCo2(greenShareLoadpoints),
hemsDimmed(site.hems),
)
}

View file

@ -36,7 +36,6 @@ type API interface {
// circuits
GetCircuit() api.Circuit
SetCircuit(api.Circuit)
//
// battery
@ -59,6 +58,7 @@ type API interface {
// power and energy
//
GetGridPower() float64
GetResidualPower() float64
SetResidualPower(float64) error

View file

@ -180,11 +180,15 @@ func (site *Site) GetCircuit() api.Circuit {
return site.circuit
}
// SetCircuit sets the root circuit
func (site *Site) SetCircuit(circuit api.Circuit) {
// SetHEMS attaches the configured HEMS to the site and the root circuit
func (site *Site) SetHEMS(hems api.HEMS) {
site.Lock()
defer site.Unlock()
site.circuit = circuit
site.hems = hems
if site.circuit != nil {
site.circuit.SetHEMS(hems)
}
}
// GetPrioritySoc returns the PrioritySoc
@ -284,6 +288,13 @@ func (site *Site) SetBufferStartSoc(soc float64) error {
return nil
}
// GetGridPower returns the most recent grid power reading in W (positive = import)
func (site *Site) GetGridPower() float64 {
site.RLock()
defer site.RUnlock()
return site.gridPower
}
// GetResidualPower returns the ResidualPower
func (site *Site) GetResidualPower() float64 {
site.RLock()

View file

@ -55,10 +55,10 @@ func (site *Site) SetBatteryMode(batMode api.BatteryMode) {
func (site *Site) updateBatteryMode(batteryGridChargeActive bool, rate api.Rate) {
batteryMode := site.requiredBatteryMode(batteryGridChargeActive, rate)
// put battery into hold mode when charging is active and circuit dimmed
// put battery into hold mode when charging is active and HEMS dimmed
fromToCharge := batteryMode == api.BatteryCharge || batteryMode == api.BatteryUnknown && site.batteryMode == api.BatteryCharge
if dimmed := circuitDimmed(site.circuit); fromToCharge && dimmed != nil && *dimmed {
site.log.DEBUG.Println("battery mode: circuit dimmed")
if dimmed := hemsDimmed(site.hems); fromToCharge && dimmed != nil && *dimmed {
site.log.DEBUG.Println("battery mode: HEMS dimmed")
batteryMode = api.BatteryHold
}

View file

@ -19,8 +19,6 @@ type circuitStruct struct {
Current *float64 `json:"current,omitempty"`
MaxPower float64 `json:"maxPower,omitempty"`
MaxCurrent float64 `json:"maxCurrent,omitempty"`
Dimmed *bool `json:"dimmed,omitempty"`
Curtailed *bool `json:"curtailed,omitempty"`
}
// publishCircuits returns a list of circuit titles
@ -44,8 +42,6 @@ func (site *Site) publishCircuits() {
Power: instance.GetChargePower(),
MaxPower: instance.GetMaxPower(),
MaxCurrent: instance.GetMaxCurrent(),
Dimmed: instance.Dimmed(),
Curtailed: instance.Curtailed(),
}
if instance.GetMaxCurrent() > 0 {
@ -76,15 +72,15 @@ func (site *Site) dimMeters(dim *bool) error {
}
} else {
if !errors.Is(err, api.ErrNotAvailable) {
errs = errors.Join(errs, fmt.Errorf("%s dimmed: %w", dev.Config().Name, err))
errs = errors.Join(errs, fmt.Errorf("%s dimmed: %w", deviceTitleOrName(dev), err))
}
continue
}
if err := m.Dim(*dim); err == nil {
site.log.DEBUG.Printf("%s dim: %t", dev.Config().Name, *dim)
site.log.DEBUG.Printf("%s dim: %t", deviceTitleOrName(dev), *dim)
} else if !errors.Is(err, api.ErrNotAvailable) {
errs = errors.Join(errs, fmt.Errorf("%s dim: %w", dev.Config().Name, err))
errs = errors.Join(errs, fmt.Errorf("%s dim: %w", deviceTitleOrName(dev), err))
}
}
@ -109,15 +105,15 @@ func (site *Site) curtailPV(curtail *bool) error {
}
} else {
if !errors.Is(err, api.ErrNotAvailable) {
errs = errors.Join(errs, fmt.Errorf("%s curtailed: %w", dev.Config().Name, err))
errs = errors.Join(errs, fmt.Errorf("%s curtailed: %w", deviceTitleOrName(dev), err))
}
continue
}
if err := m.Curtail(*curtail); err == nil {
site.log.DEBUG.Printf("%s curtail: %t", dev.Config().Name, *curtail)
site.log.DEBUG.Printf("%s curtail: %t", deviceTitleOrName(dev), *curtail)
} else if !errors.Is(err, api.ErrNotAvailable) {
errs = errors.Join(errs, fmt.Errorf("%s curtail: %w", dev.Config().Name, err))
errs = errors.Join(errs, fmt.Errorf("%s curtail: %w", deviceTitleOrName(dev), err))
}
}

View file

@ -22,8 +22,9 @@ type EEBus struct {
*eebus.Connector
cs *eebus.ControllableSystem
root api.Circuit
site site.API
passthrough func(bool) error
publishFunc func()
status status
statusUpdated time.Time
@ -83,26 +84,18 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*E
return nil, err
}
// setup grid control circuit
gridcontrol, err := smartgrid.SetupCircuit()
if err != nil {
return nil, err
}
site.SetCircuit(gridcontrol)
return NewEEBus(ctx, cc.Ski, cc.Limits, passthroughS, gridcontrol, cc.Interval)
return NewEEBus(ctx, cc.Ski, cc.Limits, passthroughS, site, cc.Interval)
}
// NewEEBus creates EEBus HEMS
func NewEEBus(ctx context.Context, ski string, limits Limits, passthrough func(bool) error, root api.Circuit, interval time.Duration) (*EEBus, error) {
func NewEEBus(ctx context.Context, ski string, limits Limits, passthrough func(bool) error, site site.API, interval time.Duration) (*EEBus, error) {
if eebus.Instance == nil {
return nil, errors.New("eebus not configured")
}
c := &EEBus{
log: util.NewLogger("eebus"),
root: root,
site: site,
passthrough: passthrough,
cs: eebus.Instance.ControllableSystem(),
Connector: eebus.NewConnector(),
@ -162,11 +155,21 @@ func NewEEBus(ctx context.Context, ski string, limits Limits, passthrough func(b
return c, nil
}
func (c *EEBus) SetUpdated(f func()) {
c.mux.Lock()
defer c.mux.Unlock()
c.publishFunc = f
}
func (c *EEBus) Run() {
for range time.Tick(c.interval) {
if err := c.run(); err != nil {
c.log.ERROR.Println(err)
}
if c.publishFunc != nil {
c.publishFunc()
}
}
}
@ -219,7 +222,11 @@ func (c *EEBus) run() error {
c.setConsumptionLimit(c.consumptionLimit.Value)
}
} else {
if time.Since(c.consumptionLimitActivated) > c.consumptionLimit.Duration {
switch {
case !c.consumptionLimit.IsActive:
c.log.DEBUG.Println("consumption limit released")
c.setConsumptionLimit(0)
case time.Since(c.consumptionLimitActivated) > c.consumptionLimit.Duration:
c.log.DEBUG.Println("consumption limit duration exceeded")
c.setConsumptionLimit(0)
c.consumptionLimit.IsActive = false
@ -233,7 +240,11 @@ func (c *EEBus) run() error {
c.setProductionLimit(c.productionLimit.Value, true)
}
} else {
if time.Since(c.productionLimitActivated) > c.productionLimit.Duration {
switch {
case !c.productionLimit.IsActive:
c.log.DEBUG.Println("production limit released")
c.setProductionLimit(0, false)
case time.Since(c.productionLimitActivated) > c.productionLimit.Duration:
c.log.DEBUG.Println("production limit duration exceeded")
c.setProductionLimit(0, false)
c.productionLimit.IsActive = false
@ -257,10 +268,7 @@ func (c *EEBus) setConsumptionLimit(limit float64) {
c.consumptionLimitActivated = time.Time{}
}
c.root.Dim(active)
c.root.SetMaxPower(limit)
if err := smartgrid.UpdateSession(&c.smartgridConsumptionId, smartgrid.Dim, c.root.GetChargePower(), limit, active); err != nil {
if err := smartgrid.UpdateSession(&c.smartgridConsumptionId, smartgrid.Dim, c.site.GetGridPower(), limit, active); err != nil {
c.log.ERROR.Printf("smartgrid session: %v", err)
}
@ -278,11 +286,52 @@ func (c *EEBus) setProductionLimit(limit float64, active bool) {
c.productionLimitActivated = time.Time{}
}
c.root.Curtail(active)
// TODO make ProductionNominalMax configurable (Site kWp)
// c.root.SetMaxProduction(limit)
if err := smartgrid.UpdateSession(&c.smartgridProductionId, smartgrid.Curtail, c.root.GetChargePower(), limit, active); err != nil {
if err := smartgrid.UpdateSession(&c.smartgridProductionId, smartgrid.Curtail, c.site.GetGridPower(), limit, active); err != nil {
c.log.ERROR.Printf("smartgrid session: %v", err)
}
}
var _ api.HEMS = (*EEBus)(nil)
// Dimmed implements api.HEMS, derived from consumptionLimitActivated.
func (c *EEBus) Dimmed() bool {
c.mux.RLock()
defer c.mux.RUnlock()
return !c.consumptionLimitActivated.IsZero()
}
// Curtailed implements api.HEMS, derived from productionLimitActivated.
func (c *EEBus) Curtailed() bool {
c.mux.RLock()
defer c.mux.RUnlock()
return !c.productionLimitActivated.IsZero()
}
// MaxConsumptionPower implements api.HEMS, returning the consumption cap
// currently in effect: failsafe limit while in failsafe, otherwise the
// EG-supplied LPC limit when active, or 0 when no limit applies.
func (c *EEBus) MaxConsumptionPower() float64 {
c.mux.RLock()
defer c.mux.RUnlock()
if c.consumptionLimitActivated.IsZero() {
return 0
}
if c.status == StatusFailsafe {
return c.failsafeConsumptionLimit
}
return c.consumptionLimit.Value
}
// MaxProductionPower implements api.HEMS. Scaffolding only — EEBus does not
// publish a wattage-typed production cap yet.
func (c *EEBus) MaxProductionPower() *float64 {
c.mux.RLock()
defer c.mux.RUnlock()
if c.productionLimitActivated.IsZero() {
return nil
}
if c.status == StatusFailsafe {
return c.failsafeProductionLimit
}
return new(c.productionLimit.Value)
}

View file

@ -5,12 +5,11 @@ import (
"time"
ucapi "github.com/enbility/eebus-go/usecases/api"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
)
const (
@ -19,10 +18,19 @@ const (
testFailsafeDuration = 2 * time.Hour
)
// stubSite implements site.API for testing — only GetGridPower is exercised;
// any other call would dereference the nil embedded interface and panic.
type stubSite struct {
site.API
gridPower float64
}
func (s *stubSite) GetGridPower() float64 { return s.gridPower }
// newTestEEBus builds a minimally-wired EEBus suitable for exercising run().
// The CS interfaces are nil — the failsafe-exit path under test does not call
// them — and smartgrid persistence is backed by an in-memory SQLite database.
func newTestEEBus(t *testing.T, root api.Circuit) *EEBus {
func newTestEEBus(t *testing.T) *EEBus {
t.Helper()
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
@ -30,7 +38,7 @@ func newTestEEBus(t *testing.T, root api.Circuit) *EEBus {
failsafeProduction := testFailsafeProduction
return &EEBus{
log: util.NewLogger("test"),
root: root,
site: &stubSite{},
heartbeat: util.NewValue[struct{}](time.Hour),
failsafeConsumptionLimit: testFailsafeConsumption,
failsafeProductionLimit: &failsafeProduction,
@ -38,35 +46,30 @@ func newTestEEBus(t *testing.T, root api.Circuit) *EEBus {
}
}
// expectConsumptionLimit programs the mock circuit to receive a consumption
// limit. limit==0 means "release" (Dim(false), SetMaxPower(0)); >0 means "apply".
func expectConsumptionLimit(c *api.MockCircuit, limit float64) {
c.EXPECT().Dim(limit > 0)
c.EXPECT().SetMaxPower(limit)
c.EXPECT().GetChargePower().Return(0.0)
// assertConsumptionLimit checks the HEMS consumption state through the api.HEMS surface.
func assertConsumptionLimit(t *testing.T, c *EEBus, limit float64) {
t.Helper()
assert.Equal(t, limit > 0, c.Dimmed())
assert.Equal(t, limit, c.MaxConsumptionPower())
}
// expectProductionLimit programs the mock circuit for a production-limit
// transition. active=true on a non-zero EG limit; false on release.
func expectProductionLimit(c *api.MockCircuit, active bool) {
c.EXPECT().Curtail(active)
c.EXPECT().GetChargePower().Return(0.0)
// assertProductionLimit checks the HEMS production state through the api.HEMS surface.
func assertProductionLimit(t *testing.T, c *EEBus, active bool) {
t.Helper()
assert.Equal(t, active, c.Curtailed())
}
// TestRun_HeartbeatLost_EntersFailsafe verifies the LPC-911/LPP-911 transition:
// a missing heartbeat in the normal state must apply the configured failsafe
// consumption and production limits.
func TestRun_HeartbeatLost_EntersFailsafe(t *testing.T) {
ctrl := gomock.NewController(t)
circuit := api.NewMockCircuit(ctrl)
c := newTestEEBus(t, circuit)
c := newTestEEBus(t)
// heartbeat never Set -> Get() returns ErrTimeout
expectConsumptionLimit(circuit, testFailsafeConsumption)
expectProductionLimit(circuit, true)
require.NoError(t, c.run())
assert.Equal(t, StatusFailsafe, c.status)
assertConsumptionLimit(t, c, testFailsafeConsumption)
assertProductionLimit(t, c, true)
}
// TestRun_FailsafeStaysOnMissingHeartbeat is the LPC-921/LPP-921 fix: when the
@ -76,9 +79,7 @@ func TestRun_HeartbeatLost_EntersFailsafe(t *testing.T) {
// StatusNormal with limit=0 once failsafeDuration elapsed, leaving the system
// unprotected until heartbeat returned.
func TestRun_FailsafeStaysOnMissingHeartbeat(t *testing.T) {
ctrl := gomock.NewController(t)
circuit := api.NewMockCircuit(ctrl)
c := newTestEEBus(t, circuit)
c := newTestEEBus(t)
c.status = StatusFailsafe
// statusUpdated set in the past beyond failsafeDuration to verify we do not
// exit failsafe based on the duration alone.
@ -97,40 +98,67 @@ func TestRun_FailsafeStaysOnMissingHeartbeat(t *testing.T) {
func TestRun_HeartbeatReturned_AppliesFreshLimit(t *testing.T) {
const freshLimit = 3000.0
ctrl := gomock.NewController(t)
circuit := api.NewMockCircuit(ctrl)
c := newTestEEBus(t, circuit)
c := newTestEEBus(t)
c.status = StatusFailsafe
c.statusUpdated = time.Now() // well within failsafeDuration
c.heartbeat.Set(struct{}{})
c.consumptionLimit = ucapi.LoadLimit{Value: freshLimit, IsActive: true}
// Exit clears the consumption limit, then the LPC-914/1 block re-applies
// the fresh value. Production was never active, so it stays at zero.
expectConsumptionLimit(circuit, 0)
expectProductionLimit(circuit, false)
expectConsumptionLimit(circuit, freshLimit)
require.NoError(t, c.run())
assert.Equal(t, StatusNormal, c.status)
// Final state is the fresh limit (the LPC-914/1 block re-applies after the release).
assertConsumptionLimit(t, c, freshLimit)
assertProductionLimit(t, c, false)
}
// TestRun_HeartbeatReturned_NoFreshLimit covers the LPC-918 release case:
// heartbeat restored but EG has no active limit pending -> exit to normal,
// no limit applied.
func TestRun_HeartbeatReturned_NoFreshLimit(t *testing.T) {
ctrl := gomock.NewController(t)
circuit := api.NewMockCircuit(ctrl)
c := newTestEEBus(t, circuit)
c := newTestEEBus(t)
c.status = StatusFailsafe
c.heartbeat.Set(struct{}{})
c.consumptionLimit = ucapi.LoadLimit{IsActive: false}
// Only the failsafe-exit release runs; the LPC-914/1 block sees no
// active limit.
expectConsumptionLimit(circuit, 0)
expectProductionLimit(circuit, false)
require.NoError(t, c.run())
assert.Equal(t, StatusNormal, c.status)
assertConsumptionLimit(t, c, 0)
assertProductionLimit(t, c, false)
}
// TestRun_ProductionLimitReleasedEarly verifies that an active production limit
// is released as soon as the EG deactivates it (IsActive=false), without waiting
// for its duration to elapse. The previous code only released on duration expiry,
// so unchecking "Activate" in the control box had no effect until the timer ran
// out (see PR #30284 report).
func TestRun_ProductionLimitReleasedEarly(t *testing.T) {
c := newTestEEBus(t)
c.heartbeat.Set(struct{}{})
// EG activates a production limit with a long duration.
c.productionLimit = ucapi.LoadLimit{IsActive: true, Duration: time.Hour}
require.NoError(t, c.run())
assertProductionLimit(t, c, true)
// EG deactivates well within the duration -> must release immediately.
c.productionLimit.IsActive = false
require.NoError(t, c.run())
assertProductionLimit(t, c, false)
}
// TestRun_ConsumptionLimitReleasedEarly is the LPC mirror of the LPP early-release
// case: an active consumption limit must drop as soon as the EG deactivates it.
func TestRun_ConsumptionLimitReleasedEarly(t *testing.T) {
c := newTestEEBus(t)
c.heartbeat.Set(struct{}{})
// EG activates a consumption limit with a long duration.
c.consumptionLimit = ucapi.LoadLimit{Value: 3000, IsActive: true, Duration: time.Hour}
require.NoError(t, c.run())
assertConsumptionLimit(t, c, 3000)
// EG deactivates well within the duration -> must release immediately.
c.consumptionLimit.IsActive = false
require.NoError(t, c.run())
assertConsumptionLimit(t, c, 0)
}

View file

@ -19,7 +19,7 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*F
cc := struct {
MaxPower float64 // TODO deprecated
MaxDimPower float64
MaxCurtailPower *float64
MaxCurtailPower float64
W3 *plugin.Config
S1 *plugin.Config
S2 *plugin.Config
@ -33,14 +33,6 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*F
return nil, err
}
// setup grid control circuit
gridcontrol, err := smartgrid.SetupCircuit()
if err != nil {
return nil, err
}
site.SetCircuit(gridcontrol)
w3G, err := cc.W3.BoolGetter(ctx)
if err != nil {
return nil, err
@ -67,19 +59,18 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*F
}
maxDimPower := math.Abs(cc.MaxDimPower)
if w4G != nil && maxDimPower == 0 {
return nil, errors.New("cannot have w4 without power limit")
}
var maxCurtailPower *float64
switch {
case cc.MaxCurtailPower != nil:
maxCurtailPower = new(math.Abs(*cc.MaxCurtailPower))
case cc.MaxPower > 0:
// fnn-3 backwards compatibility: legacy MaxPower was the PV/curtail cap
maxCurtailPower = new(math.Abs(cc.MaxPower))
maxCurtailPower := math.Abs(cc.MaxCurtailPower)
if cc.MaxPower > 0 {
maxCurtailPower = cc.MaxPower
}
return &Fnn{
log: util.NewLogger("fnn"),
root: gridcontrol,
site: site,
maxDimPower: maxDimPower,
maxCurtailPower: maxCurtailPower,
s1: s1G,
@ -95,24 +86,32 @@ type Fnn struct {
mu sync.Mutex
log *util.Logger
root api.Circuit
s1, s2, w3 func() (bool, error)
w4 func() (bool, error)
site site.API
s1, s2, w3 func() (bool, error)
w4 func() (bool, error)
publishFunc func()
maxDimPower float64
maxCurtailPower float64
smartgridConsumptionID uint
smartgridProductionID uint
maxDimPower float64
maxCurtailPower *float64
interval time.Duration
consumptionLimit float64
productionLimit *float64
interval time.Duration
}
func (c *Fnn) SetUpdated(f func()) {
c.mu.Lock()
defer c.mu.Unlock()
c.publishFunc = f
}
// Run starts the FNN control loop.
func (c *Fnn) Run() {
ticker := time.NewTicker(c.interval)
defer ticker.Stop()
for range ticker.C {
for range time.Tick(c.interval) {
if err := c.runCurtail(); err != nil {
c.log.ERROR.Println(err)
}
@ -120,6 +119,10 @@ func (c *Fnn) Run() {
if err := c.runDim(); err != nil {
c.log.ERROR.Println(err)
}
if c.publishFunc != nil {
c.publishFunc()
}
}
}
@ -172,60 +175,89 @@ func (c *Fnn) runDim() error {
limit := 0.0
if active {
if c.maxDimPower <= 0 {
return errors.New("dim active but no limit configured")
}
limit = c.maxDimPower
}
return c.setConsumptionLimit(limit)
}
func (c *Fnn) applyMode(id *uint, typ smartgrid.Type, active bool, limit float64, applyRoot func()) {
applyRoot()
if err := smartgrid.UpdateSession(id, typ, c.root.GetChargePower(), limit, active); err != nil {
c.log.ERROR.Printf("smartgrid session: %v", err)
}
}
// setProductionLimit applies the curtailment limit to the circuit.
// setProductionLimit applies the curtailment limit.
func (c *Fnn) setProductionLimit(frac float64) error {
c.mu.Lock()
defer c.mu.Unlock()
limit := 0.0
active := frac < 1.0
c.productionLimit = nil
if active {
if c.maxCurtailPower == nil {
return errors.New("curtail active but no limit configured")
}
limit = *c.maxCurtailPower * frac
c.productionLimit = new(c.maxCurtailPower * frac)
}
c.applyMode(&c.smartgridProductionID, smartgrid.Curtail, active, limit, func() {
c.root.Curtail(active)
// TODO make ProductionNominalMax configurable (Site kWp)
// c.root.SetMaxPower(c.maxPower*frac)
})
limit := 0.0
if c.productionLimit != nil {
limit = *c.productionLimit
}
if err := smartgrid.UpdateSession(&c.smartgridProductionID, smartgrid.Curtail, c.site.GetGridPower(), limit, active); err != nil {
c.log.ERROR.Printf("smartgrid session: %v", err)
}
return nil
}
// setConsumptionLimit applies the dimming limit to the circuit.
// setConsumptionLimit applies the dimming limit.
func (c *Fnn) setConsumptionLimit(limit float64) error {
c.mu.Lock()
defer c.mu.Unlock()
active := limit > 0
c.consumptionLimit = limit
c.applyMode(&c.smartgridConsumptionID, smartgrid.Dim, active, limit, func() {
c.root.Dim(active)
c.root.SetMaxPower(limit)
})
if err := smartgrid.UpdateSession(&c.smartgridConsumptionID, smartgrid.Dim, c.site.GetGridPower(), limit, active); err != nil {
c.log.ERROR.Printf("smartgrid session: %v", err)
}
return nil
}
var _ api.HEMS = (*Fnn)(nil)
// Dimmed implements api.HEMS.
func (c *Fnn) Dimmed() bool {
if c.w4 == nil {
return false
}
c.mu.Lock()
defer c.mu.Unlock()
return c.consumptionLimit > 0
}
// Curtailed implements api.HEMS.
func (c *Fnn) Curtailed() bool {
if c.w3 == nil {
return false
}
c.mu.Lock()
defer c.mu.Unlock()
return c.productionLimit != nil
}
// MaxConsumptionPower implements api.HEMS.
func (c *Fnn) MaxConsumptionPower() float64 {
c.mu.Lock()
defer c.mu.Unlock()
return c.consumptionLimit
}
// MaxProductionPower implements api.HEMS.
func (c *Fnn) MaxProductionPower() *float64 {
c.mu.Lock()
defer c.mu.Unlock()
if c.productionLimit == nil {
return nil
}
return new(*c.productionLimit)
}

View file

@ -1,6 +1,10 @@
package hems
// API describes the HEMS system interface
import "github.com/evcc-io/evcc/api"
// API describes the HEMS system interface combining the runtime loop
// with the api.HEMS state-query surface consumed by Circuit, Site and Loadpoint.
type API interface {
api.HEMS
Run()
}

View file

@ -2,6 +2,7 @@ package relay
import (
"context"
"errors"
"fmt"
"sync"
"time"
@ -17,9 +18,10 @@ type Relay struct {
mu sync.Mutex
log *util.Logger
root api.Circuit
site site.API
w1 func() (bool, error)
passthrough func(bool) error
publishFunc func()
smartgridID uint
limit *float64
@ -42,14 +44,6 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*R
return nil, err
}
// setup grid control circuit
gridcontrol, err := smartgrid.SetupCircuit()
if err != nil {
return nil, err
}
site.SetCircuit(gridcontrol)
// limit getter
limitG, err := cc.Limit.BoolGetter(ctx)
if err != nil {
@ -61,28 +55,42 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*R
return nil, err
}
return NewRelay(gridcontrol, limitG, passthroughS, cc.MaxPower, cc.Interval)
return NewRelay(site, limitG, passthroughS, cc.MaxPower, cc.Interval)
}
// NewRelay creates Relay HEMS
func NewRelay(root api.Circuit, w1 func() (bool, error), passthrough func(bool) error, maxPower float64, interval time.Duration) (*Relay, error) {
func NewRelay(site site.API, w1 func() (bool, error), passthrough func(bool) error, maxPower float64, interval time.Duration) (*Relay, error) {
c := &Relay{
log: util.NewLogger("relay"),
root: root,
site: site,
passthrough: passthrough,
maxPower: maxPower,
w1: w1,
interval: interval,
}
if maxPower == 0 {
return nil, errors.New("missing power limit")
}
return c, nil
}
func (c *Relay) SetUpdated(f func()) {
c.mu.Lock()
defer c.mu.Unlock()
c.publishFunc = f
}
func (c *Relay) Run() {
for range time.Tick(c.interval) {
if err := c.run(); err != nil {
c.log.ERROR.Println(err)
}
if c.publishFunc != nil {
c.publishFunc()
}
}
}
@ -97,18 +105,18 @@ func (c *Relay) run() error {
limit = c.maxPower
}
if err := c.setLimited(limit); err != nil {
if err := c.setConsumptionLimit(limit); err != nil {
return err
}
if err := smartgrid.UpdateSession(&c.smartgridID, smartgrid.Dim, c.root.GetChargePower(), limit, active); err != nil {
if err := smartgrid.UpdateSession(&c.smartgridID, smartgrid.Dim, c.site.GetGridPower(), limit, active); err != nil {
return fmt.Errorf("smartgrid session: %v", err)
}
return nil
}
func (c *Relay) setLimited(limit float64) error {
func (c *Relay) setConsumptionLimit(limit float64) error {
c.mu.Lock()
defer c.mu.Unlock()
@ -117,9 +125,6 @@ func (c *Relay) setLimited(limit float64) error {
c.limit = new(limit)
}
c.root.Dim(limit > 0)
c.root.SetMaxPower(limit)
if c.passthrough != nil {
if err := c.passthrough(limit > 0); err != nil {
return fmt.Errorf("passthrough failed: %w", err)
@ -128,3 +133,32 @@ func (c *Relay) setLimited(limit float64) error {
return nil
}
var _ api.HEMS = (*Relay)(nil)
// Dimmed implements api.HEMS, derived from the active consumption limit.
func (c *Relay) Dimmed() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.limit != nil
}
// Curtailed implements api.HEMS. Relay does not curtail production.
func (c *Relay) Curtailed() bool {
return false
}
// MaxConsumptionPower implements api.HEMS, returning the active wattage cap.
func (c *Relay) MaxConsumptionPower() float64 {
c.mu.Lock()
defer c.mu.Unlock()
if c.limit == nil {
return 0
}
return *c.limit
}
// MaxProductionPower implements api.HEMS. Scaffolding only.
func (c *Relay) MaxProductionPower() *float64 {
return nil
}

View file

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

@ -131,8 +131,6 @@
"feedinPrice": "Цена за изкупуване",
"gridPrice": "Цена на мрежата",
"heaterTempLimit": "Лимит на загревателя",
"hemsActiveLimit": "Активен лимит",
"hemsType": "Комуникация",
"identifier": "RFID идентификатор",
"no": "не",
"odometer": "одометър",
@ -158,10 +156,6 @@
"B": "Б (свързан)",
"C": "В (зарежда се)"
},
"deviceValueHemsType": {
"eebus": "през EEBus",
"relay": "през реле"
},
"devices": {
"auxMeter": "Смарт потребител",
"batteryStorage": "Съхранение на батерии",
@ -918,7 +912,6 @@
"waitForVehicle": "Готово. Чака се нагревателят …"
},
"hemsWarning": {
"description": "Намалено зареждане, което да не надвишава {limit}.",
"title": "Външен лимит:"
},
"loadpoint": {

View file

@ -131,8 +131,6 @@
"feedinPrice": "Tarifa za prodaju električne energije iz obnovljivih izvora",
"gridPrice": "Mrežna cijena",
"heaterTempLimit": "Ograničenje grijača",
"hemsActiveLimit": "Aktivni limit",
"hemsType": "Komunikacija",
"identifier": "RFID identifikator",
"no": "ne",
"odometer": "brojač kilometara",
@ -158,10 +156,6 @@
"B": "B (povezano)",
"C": "C (punjenje)"
},
"deviceValueHemsType": {
"eebus": "putem EEBus-a",
"relay": "putem štafete"
},
"devices": {
"auxMeter": "Pametni potrošač",
"batteryStorage": "Skladištenje baterija",
@ -394,9 +388,6 @@
"loadpoints": "Punjač | Punjač | {count} punjača",
"loadpointsLimit": "{limit} ograničenje"
},
"hemsWarning": {
"description": "Smanjiti punjenje kako ne bi prelazilo {limit}."
},
"loadpoint": {
"remoteDisabledHard": "{source}: isključen",
"remoteDisabledSoft": "{source}: isključeno prilagodljivo solarno punjenje"

View file

@ -150,8 +150,6 @@
"forecast": "Předpověď",
"gridPrice": "Cena ze sítě",
"heaterTempLimit": "Limit topení",
"hemsActiveLimit": "Aktivní limit",
"hemsType": "Komunikace",
"identifier": "RFID identifikátor",
"loginBlocked": "Dosažen limit přihlášení",
"max": "maximum",
@ -181,10 +179,6 @@
"B": "B (připojeno)",
"C": "C (nabíjení)"
},
"deviceValueHemsType": {
"eebus": "skrze EEBus",
"relay": "skrze relé"
},
"devices": {
"auxMeter": "Chytrý spotřebič",
"batteryStorage": "Bateriové úložiště",
@ -1197,7 +1191,6 @@
"waitForVehicle": "Připraven k vytápění…"
},
"hemsWarning": {
"description": "Zpomalené nabíjení zamezující přesáhnutí {limit}.",
"title": "Externí limit:"
},
"history": {

View file

@ -150,8 +150,6 @@
"forecast": "Prognose",
"gridPrice": "God pris",
"heaterTempLimit": "Opvarmning begrænsning",
"hemsActiveLimit": "Aktiv begrænsning",
"hemsType": "Kommunikation",
"identifier": "RFID-identifikator",
"loginBlocked": "Grænsen for loginforsøg er nået",
"max": "max",
@ -182,10 +180,6 @@
"B": "B (forbundet)",
"C": "C (oplader)"
},
"deviceValueHemsType": {
"eebus": "via EEBus",
"relay": "via Relay"
},
"devices": {
"auxMeter": "Smart forbrugsenhed",
"batteryStorage": "Batterilagring",
@ -1200,7 +1194,6 @@
"waitForVehicle": "Klar til at varme…"
},
"hemsWarning": {
"description": "Opladningen reduceres for ikke at overskride {limit}.",
"title": "Ekstern begrænsning:"
},
"history": {

View file

@ -155,8 +155,11 @@
"currency": "Währung",
"current": "Strom",
"currentRange": "Strom",
"curtailLimit": "Einspeiselimit",
"curtailable": "Abregelbar",
"curtailed": "Einspeisung begrenzt",
"detected": "Erkannt",
"dimLimit": "Verbrauchslimit",
"dimmed": "Verbrauch begrenzt",
"enabled": "Ladebereit",
"energy": "Energie",
@ -165,8 +168,6 @@
"forecast": "Vorhersage",
"gridPrice": "Netzpreis",
"heaterTempLimit": "Heizungslimit",
"hemsActiveLimit": "Aktives Limit",
"hemsType": "Kommunikation",
"identifier": "RFID-Kennung",
"loginBlocked": "Login-Limit erreicht",
"max": "max",
@ -181,6 +182,7 @@
"power": "Leistung",
"powerRange": "Leistung",
"price": "Preis",
"productionLimited": "Erzeugung begrenzt",
"range": "Reichweite",
"returnEnergy": "Energie (rückwärts)",
"singlePhase": "Einphasig",
@ -197,10 +199,6 @@
"B": "B (verbunden)",
"C": "C (lädt)"
},
"deviceValueHemsType": {
"eebus": "via EEBus",
"relay": "via Relais"
},
"devices": {
"auxMeter": "Smarter Verbraucher",
"batteryStorage": "Batterie",
@ -1230,8 +1228,9 @@
"waitForVehicle": "Bereit zum Heizen …"
},
"hemsWarning": {
"description": "Reduziere Ladeleistung, um {limit} nicht zu überschreiten.",
"title": "Externe Begrenzung:"
"consumption": "Verbrauch",
"feedIn": "Einspeisung",
"title": "Externe Begrenzung aktiv"
},
"history": {
"direction": {

View file

@ -131,8 +131,6 @@
"feedinPrice": "Ταρίφα Feed-in",
"gridPrice": "Τιμή δικτύου",
"heaterTempLimit": "Όριο θερμαντήρα",
"hemsActiveLimit": "Ενεργό όριο",
"hemsType": "Επικοινωνία",
"identifier": "Αναγνωριστικό RFID",
"no": "όχι",
"odometer": "Οδόμετρο",
@ -158,10 +156,6 @@
"B": "B (συνδεδεμένο)",
"C": "C (φορτίζει)"
},
"deviceValueHemsType": {
"eebus": "μέσω EEBus",
"relay": "μέσω ρελέ"
},
"devices": {
"auxMeter": "Έξυπνος καταναλωτής",
"batteryStorage": "Χωριτικότητα μπαταρίας",
@ -923,7 +917,6 @@
"waitForVehicle": "Έτοιμο. Αναμονή για θερμαντήρα…"
},
"hemsWarning": {
"description": "Μειωμένη φόρτιση ώστε να μην υπερβαίνει το {limit}.",
"title": "Εξωτερικό όριο:"
},
"loadpoint": {

View file

@ -155,8 +155,11 @@
"currency": "Currency",
"current": "Current",
"currentRange": "Current",
"curtailLimit": "Feed-in limit",
"curtailable": "Curtailable",
"curtailed": "Feed-in limited",
"detected": "Detected",
"dimLimit": "Consumption limit",
"dimmed": "Consumption limited",
"enabled": "Enabled",
"energy": "Energy",
@ -165,8 +168,6 @@
"forecast": "Forecast",
"gridPrice": "Grid price",
"heaterTempLimit": "Heater limit",
"hemsActiveLimit": "Active limit",
"hemsType": "Communication",
"identifier": "RFID-Identifier",
"loginBlocked": "Login limit reached",
"max": "max",
@ -181,6 +182,7 @@
"power": "Power",
"powerRange": "Power",
"price": "Price",
"productionLimited": "Production limited",
"range": "Range",
"returnEnergy": "Energy (reverse)",
"singlePhase": "Single phase",
@ -197,10 +199,6 @@
"B": "B (connected)",
"C": "C (charging)"
},
"deviceValueHemsType": {
"eebus": "via EEBus",
"relay": "via Relay"
},
"devices": {
"auxMeter": "Smart consumer",
"batteryStorage": "Battery storage",
@ -1229,8 +1227,9 @@
"waitForVehicle": "Ready to heat…"
},
"hemsWarning": {
"description": "Reduced charging to not exceed {limit}.",
"title": "External limit:"
"consumption": "Consumption",
"feedIn": "Feed-in",
"title": "External limit active"
},
"history": {
"direction": {

View file

@ -131,8 +131,6 @@
"feedinPrice": "Precio de entrada",
"gridPrice": "Precio de red",
"heaterTempLimit": "Límite del calentador",
"hemsActiveLimit": "Límite activo",
"hemsType": "Comunicación",
"identifier": "Identificador RFID",
"no": "no",
"odometer": "Cuentakilómetros",
@ -158,10 +156,6 @@
"B": "B (conectado)",
"C": "C (carga)"
},
"deviceValueHemsType": {
"eebus": "a través de EEBus",
"relay": "a través de Relay"
},
"devices": {
"auxMeter": "Consumidor inteligente",
"batteryStorage": "Almacenamiento en baterías",
@ -935,7 +929,6 @@
"waitForVehicle": "Listo. Esperando el calentador…"
},
"hemsWarning": {
"description": "Carga reducida para no superar {limit}.",
"title": "Límite externo:"
},
"loadpoint": {

View file

@ -85,8 +85,6 @@
"forecast": "Ennustus",
"gridPrice": "Võrguhind",
"heaterTempLimit": "Soojenduse piir",
"hemsActiveLimit": "Aktiivne piir",
"hemsType": "Suhtlusviis",
"identifier": "RFID-tunnus",
"loginBlocked": "Sisselogimiste piir on käes",
"max": "enim",

View file

@ -150,8 +150,6 @@
"forecast": "Ennuste",
"gridPrice": "Verkosta ostonhinta",
"heaterTempLimit": "Lämmityksen raja",
"hemsActiveLimit": "Aktiivinen raja",
"hemsType": "Kommunikaatiomenetelmä",
"identifier": "RFID-tunniste",
"loginBlocked": "Sisäänkirjoitumisyritysten raja saavutettu",
"max": "maksimi",
@ -182,10 +180,6 @@
"B": "B (yhdistetty)",
"C": "C (lataa)"
},
"deviceValueHemsType": {
"eebus": "EEBusilla",
"relay": "Relayllä"
},
"devices": {
"auxMeter": "Älykkään virranhallinnan laite",
"batteryStorage": "Akkujärjestelmä",
@ -1199,7 +1193,6 @@
"waitForVehicle": "Lämmitystä aloitetaan…"
},
"hemsWarning": {
"description": "Rajoitettu latausta, jotta rajaa {limit} ei ylitettäisi.",
"title": "Ulkoinen raja:"
},
"history": {

View file

@ -150,8 +150,6 @@
"forecast": "Prévisions",
"gridPrice": "Prix du réseau",
"heaterTempLimit": "Limite du chauffage",
"hemsActiveLimit": "Limite active",
"hemsType": "Communication",
"identifier": "Identifiant RFID",
"loginBlocked": "Limit de login atteinte",
"max": "max",
@ -182,10 +180,6 @@
"B": "B (connecté)",
"C": "C (en charge)"
},
"deviceValueHemsType": {
"eebus": "via EEBus",
"relay": "via Relai"
},
"devices": {
"auxMeter": "Consommateur intelligent",
"batteryStorage": "Stockage batterie",
@ -1237,7 +1231,6 @@
"waitForVehicle": "Prêt à chauffer…"
},
"hemsWarning": {
"description": "Réduire la charge pour ne pas dépasser {limit}.",
"title": "Limite externe :"
},
"history": {

View file

@ -131,8 +131,6 @@
"feedinPrice": "Cijena predaje u mrežu opskrbljivača",
"gridPrice": "Cijena uvoza",
"heaterTempLimit": "Ograničenje grijača",
"hemsActiveLimit": "Ograničenje aktivnosti",
"hemsType": "Komunikacija",
"identifier": "RFID-identifikator",
"no": "ne",
"odometer": "Brojilo kilometara",
@ -158,10 +156,6 @@
"B": "B (povezano)",
"C": "C (punjenje)"
},
"deviceValueHemsType": {
"eebus": "putem EEBus",
"relay": "putem Relay"
},
"devices": {
"auxMeter": "Pametni potrošač",
"batteryStorage": "Baterijski spremnik",
@ -924,7 +918,6 @@
"waitForVehicle": "Spremno. Čeka se grijač…"
},
"hemsWarning": {
"description": "Punjenje je smanjeno da se ne prekorači {limit}.",
"title": "Externo ograničenje:"
},
"loadpoint": {

View file

@ -133,8 +133,6 @@
"forecast": "Előrejelzés",
"gridPrice": "Villamosenergia ára",
"heaterTempLimit": "Fűtés limit",
"hemsActiveLimit": "Aktív limit",
"hemsType": "Kommunikáció",
"identifier": "RFID-Azonosító",
"messengers": "Szolgáltatások",
"no": "nem",
@ -161,10 +159,6 @@
"B": "B (csatlakoztatva)",
"C": "C (töltés)"
},
"deviceValueHemsType": {
"eebus": "EEBus által",
"relay": "Relé által"
},
"devices": {
"auxMeter": "Okos fogyasztó",
"batteryStorage": "Akkumulátoros tároló",
@ -934,7 +928,6 @@
"waitForVehicle": "Üzemkész. Fűtésre várakozás…"
},
"hemsWarning": {
"description": "Csökkentett töltés, amely nem haladhatja meg a {limit}-et.",
"title": "Külső határérték:"
},
"loadpoint": {

View file

@ -143,8 +143,6 @@
"forecast": "Previsione",
"gridPrice": "Prezzo d'acquisto",
"heaterTempLimit": "Limite del dispositivo di riscaldamento",
"hemsActiveLimit": "Limite attivo",
"hemsType": "Comunicazione",
"identifier": "Identificatore RFID",
"loginBlocked": "Limite login raggiunto",
"max": "max",
@ -174,10 +172,6 @@
"B": "B (collegato)",
"C": "C (in carica)"
},
"deviceValueHemsType": {
"eebus": "tramite EEBus",
"relay": "tramite Relay"
},
"devices": {
"auxMeter": "Dispositivo smart",
"batteryStorage": "Batteria di accumulo",
@ -1169,7 +1163,6 @@
"waitForVehicle": "Pronto. In attesa del calorifero…"
},
"hemsWarning": {
"description": "Riduzione della ricarica fino a un massimo di {limit}.",
"title": "Limite esterno:"
},
"loadpoint": {

View file

@ -146,8 +146,6 @@
"forecast": "予測",
"gridPrice": "電気料金",
"heaterTempLimit": "ヒーターリミット",
"hemsActiveLimit": "アクティブリミット",
"hemsType": "コミュニケーション",
"identifier": "RFID-識別子",
"loginBlocked": "ログイン試行回数の上限に達しました",
"max": "最大",
@ -177,10 +175,6 @@
"B": "B(接続済み)",
"C": "C(充電中)"
},
"deviceValueHemsType": {
"eebus": "EEBus経由",
"relay": "リレー経由"
},
"devices": {
"auxMeter": "スマート機器",
"batteryStorage": "蓄電池",
@ -1199,7 +1193,6 @@
"waitForVehicle": "加熱開始を待機中…"
},
"hemsWarning": {
"description": "{limit} を超えないよう出力を抑制しています。",
"title": "外部システム制限:"
},
"history": {

View file

@ -150,8 +150,6 @@
"forecast": "Previsioun",
"gridPrice": "Netz Präis",
"heaterTempLimit": "Limitt vun der Heizung",
"hemsActiveLimit": "Aktiivt Limit",
"hemsType": "Communicatioun",
"identifier": "RFID-Identifikatioun",
"loginBlocked": "Login-Limit erreecht",
"max": "max",
@ -181,10 +179,6 @@
"B": "B (verbonnen)",
"C": "C (luet)"
},
"deviceValueHemsType": {
"eebus": "via EEBus",
"relay": "via Relais"
},
"devices": {
"auxMeter": "Smarte Verbraucher",
"batteryStorage": "Batterie stockage",
@ -1197,7 +1191,6 @@
"waitForVehicle": "Prett fir ze Heizen…"
},
"hemsWarning": {
"description": "Reduzéier d'Luedleeschtung, fir {limit} net ze iwwerschreiden.",
"title": "Extern Begrenzung:"
},
"history": {

View file

@ -150,8 +150,6 @@
"forecast": "Prognozė",
"gridPrice": "Energijos pirkimo kaina",
"heaterTempLimit": "Šildymo limitas",
"hemsActiveLimit": "Aktyvus limitas",
"hemsType": "Komunikacija",
"identifier": "RFID-Identifikatorius",
"loginBlocked": "Pasiektas prisijungimų limitas",
"max": "max",
@ -182,10 +180,6 @@
"B": "B (prijungtas)",
"C": "C (įkrauna)"
},
"deviceValueHemsType": {
"eebus": "naudoja EEBus",
"relay": "naudoja Relė"
},
"devices": {
"auxMeter": "Išmanusis vartotojas",
"batteryStorage": "Energijos kaupiklis",
@ -1200,7 +1194,6 @@
"waitForVehicle": "Paruošta šildyti…"
},
"hemsWarning": {
"description": "Įkrovimas apribotas, kad neviršyti {limit}.",
"title": "Išorinis limitas:"
},
"history": {

View file

@ -150,8 +150,6 @@
"forecast": "Voorspelling",
"gridPrice": "Netprijs",
"heaterTempLimit": "Verwarming limiet",
"hemsActiveLimit": "Actieve limiet",
"hemsType": "Communicatie",
"identifier": "RFID-Nummer",
"loginBlocked": "Inloglimiet bereikt",
"max": "max",
@ -181,10 +179,6 @@
"B": "B (aangesloten)",
"C": "C (opladen)"
},
"deviceValueHemsType": {
"eebus": "via EEBus",
"relay": "via Relais"
},
"devices": {
"auxMeter": "Slimme verbruiker",
"batteryStorage": "Batterijopslag",
@ -1194,7 +1188,6 @@
"waitForVehicle": "Klaar om te verwarmen…"
},
"hemsWarning": {
"description": "Gereduceerd laden niet hoger dan {limit}.",
"title": "Externe limiet:"
},
"history": {

View file

@ -144,8 +144,6 @@
"forecast": "Varsel",
"gridPrice": "Nettpris",
"heaterTempLimit": "Varmebegrensning",
"hemsActiveLimit": "Aktiv grense",
"hemsType": "Kommunikasjon",
"identifier": "RFID-Identifikator",
"max": "maks",
"messengers": "Tjenester",
@ -174,10 +172,6 @@
"B": "B (tilkoblet)",
"C": "C (lading)"
},
"deviceValueHemsType": {
"eebus": "via EEBus",
"relay": "via relé"
},
"devices": {
"auxMeter": "Smart forbruker",
"batteryStorage": "Batterilagring",
@ -951,7 +945,6 @@
"waitForVehicle": "Klar. Venter på varmeapparatet…"
},
"hemsWarning": {
"description": "Redusert lading til ikke over {limit}.",
"title": "Ekstern grense:"
},
"loadpoint": {

View file

@ -149,8 +149,6 @@
"forecast": "Prognoza",
"gridPrice": "Cena z sieci",
"heaterTempLimit": "Limit podgrzewacza",
"hemsActiveLimit": "Limit aktywny",
"hemsType": "Komunikacja",
"identifier": "Identyfikator RFID",
"max": "maks",
"messengers": "Usługi",
@ -179,10 +177,6 @@
"B": "B (podłączony)",
"C": "C (ładowanie)"
},
"deviceValueHemsType": {
"eebus": "przez EEBus",
"relay": "przez Relay"
},
"devices": {
"auxMeter": "Inteligentne urządzenie konsumujące",
"batteryStorage": "Magazyn energii",
@ -1102,7 +1096,6 @@
"waitForVehicle": "Gotowe. Czekam na podgrzewacz…"
},
"hemsWarning": {
"description": "Obniżenie poboru energii, nieprzekraczające {limit}.",
"title": "Limit zewnętrzny:"
},
"loadpoint": {

View file

@ -143,8 +143,6 @@
"forecast": "Previsões",
"gridPrice": "Preço da Rede",
"heaterTempLimit": "Limite do aquecimento",
"hemsActiveLimit": "Limite ativo",
"hemsType": "Comunicação",
"identifier": "Identificador RFID",
"loginBlocked": "Limite de login atingido",
"max": "max",
@ -174,10 +172,6 @@
"B": "B (ligado)",
"C": "C (a carregar)"
},
"deviceValueHemsType": {
"eebus": "via EEBus",
"relay": "via Relay"
},
"devices": {
"auxMeter": "“Consumidor inteligente”",
"batteryStorage": "“Bateria”",
@ -1181,7 +1175,6 @@
"waitForVehicle": "Pronto. À espera do aquecedor…"
},
"hemsWarning": {
"description": "Reduzir a carga de modo a não exceder {limit}.",
"title": "Limite externo:"
},
"loadpoint": {

View file

@ -139,8 +139,6 @@
"forecast": "Prognoză",
"gridPrice": "Prețul rețelei",
"heaterTempLimit": "Limită încălzitor",
"hemsActiveLimit": "Limită activă",
"hemsType": "Comunicare",
"identifier": "Identificator RFID",
"max": "max",
"messengers": "Servicii",
@ -169,10 +167,6 @@
"B": "B (conectat)",
"C": "C (încărcare)"
},
"deviceValueHemsType": {
"eebus": "prin EEBus",
"relay": "prin releu"
},
"devices": {
"auxMeter": "Consumator inteligent",
"batteryStorage": "Stocarea bateriei",
@ -1084,7 +1078,6 @@
"waitForVehicle": "Pregatit. Astept dupa incalzitor…"
},
"hemsWarning": {
"description": "Încărcare redusă pentru a nu depăși {limit}.",
"title": "Limită externă:"
},
"loadpoint": {

View file

@ -131,8 +131,6 @@
"feedinPrice": "Цена отбора электроэнергии",
"gridPrice": "Цена сетки",
"heaterTempLimit": "Ограничение нагревателя",
"hemsActiveLimit": "Активный лимит",
"hemsType": "Коммуникация",
"identifier": "RFID-идентификатор",
"no": "нет",
"odometer": "Одометр",
@ -158,10 +156,6 @@
"B": "B (подключено)",
"C": "C (зарядка)"
},
"deviceValueHemsType": {
"eebus": "через EEBus",
"relay": "через реле"
},
"devices": {
"auxMeter": "Умный потребитель",
"batteryStorage": "Аккумуляторное хранилище",
@ -914,7 +908,6 @@
"waitForVehicle": "Готов. Жду нагревателя…"
},
"hemsWarning": {
"description": "Снижение заряда до уровня, не превышающего {limit}.",
"title": "Внешний предел:"
},
"loadpoint": {

View file

@ -131,8 +131,6 @@
"feedinPrice": "Cena prejete energije iz omrežja",
"gridPrice": "Cena omrežja",
"heaterTempLimit": "Omejitev ogrevanja",
"hemsActiveLimit": "Aktivna omejitev",
"hemsType": "Komunikacija",
"identifier": "RFID-identifikator",
"no": "ne",
"odometer": "Število kilometrov",
@ -158,10 +156,6 @@
"B": "B (povezan)",
"C": "C (polnjenje)"
},
"deviceValueHemsType": {
"eebus": "preko EEBus",
"relay": "prek releja"
},
"devices": {
"auxMeter": "Pametni porabnik",
"batteryStorage": "Baterijski hranilnik",
@ -945,7 +939,6 @@
"waitForVehicle": "Pripravljen. Čakam na grelec…"
},
"hemsWarning": {
"description": "Upočasnjeno polnjenje, da se ne preseže {limit}.",
"title": "Zunanja omejitev:"
},
"loadpoint": {

View file

@ -150,8 +150,6 @@
"forecast": "Prognos",
"gridPrice": "Nätpris",
"heaterTempLimit": "Värmarbegränsning",
"hemsActiveLimit": "Aktiv begränsning",
"hemsType": "Kommunikation",
"identifier": "RFID-identifierare",
"loginBlocked": "Inloggningsgräns nådd",
"max": "max",
@ -182,10 +180,6 @@
"B": "B (ansluten)",
"C": "C (laddar)"
},
"deviceValueHemsType": {
"eebus": "via EEBus",
"relay": "via relä"
},
"devices": {
"auxMeter": "Smart förbrukare",
"batteryStorage": "Batterilager",
@ -1201,7 +1195,6 @@
"waitForVehicle": "Redo att värma…"
},
"hemsWarning": {
"description": "Reducerad laddning för att inte överskrida {limit}.",
"title": "Extern begränsning:"
},
"history": {

View file

@ -132,8 +132,6 @@
"feedinPrice": "ஃபீட்-இன் விலை",
"gridPrice": "கட்டம் விலை",
"heaterTempLimit": "ஈட்டர் வரம்பு",
"hemsActiveLimit": "செயலில் வரம்பு",
"hemsType": "தொடர்பு",
"identifier": "RFID-IDENTIFIER",
"messengers": "சேவைகள்",
"no": "இல்லை",
@ -160,10 +158,6 @@
"B": "பி (இணைக்கப்பட்டுள்ளது)",
"C": "சி (சார்சிங்)"
},
"deviceValueHemsType": {
"eebus": "EEBus வழியாக",
"relay": "ரிலே வழியாக"
},
"devices": {
"auxMeter": "அறிவுள்ள நுகர்வோர்",
"batteryStorage": "பேட்டரி சேமிப்பு",
@ -1002,7 +996,6 @@
"waitForVehicle": "Ready. Waiting க்கு heater…"
},
"hemsWarning": {
"description": "{limit} ஐ விட அதிகமாகக் குறைக்கப்பட்ட சார்சிங்.",
"title": "வெளிப்புற வரம்பு:"
},
"loadpoint": {

View file

@ -150,8 +150,6 @@
"forecast": "Tahmin",
"gridPrice": "Alım fiyatı",
"heaterTempLimit": "Isıtıcı sınırlaması",
"hemsActiveLimit": "Etkin sınır",
"hemsType": "İletişim",
"identifier": "RFID tanımlayıcısı",
"loginBlocked": "Giriş sınırına ulaşıldı",
"max": "azami",
@ -181,10 +179,6 @@
"B": "“B (bağlı)”",
"C": "“C (dolduruyor)”"
},
"deviceValueHemsType": {
"eebus": "EEBus aracılığıyla",
"relay": "Röle aracılığıyla"
},
"devices": {
"auxMeter": "“Akıllı tüketici”",
"batteryStorage": "“Batarya”",
@ -1197,7 +1191,6 @@
"waitForVehicle": "Isıtmaya hazır…"
},
"hemsWarning": {
"description": "Doldurmayı {limit} değerini aşmayacak şekilde azalt.",
"title": "Harici sınır:"
},
"history": {

View file

@ -131,8 +131,6 @@
"feedinPrice": "Збірна ціна",
"gridPrice": "Ціна сітки",
"heaterTempLimit": "Обмеження нагрівача",
"hemsActiveLimit": "Активний ліміт",
"hemsType": "Зв'язок",
"identifier": "RFID-ідентифікатор",
"no": "ні",
"odometer": "Одометр",
@ -158,10 +156,6 @@
"B": "B (підключено)",
"C": "C (зарядка)"
},
"deviceValueHemsType": {
"eebus": "через EEBus",
"relay": "через реле"
},
"devices": {
"auxMeter": "Розумний споживач",
"batteryStorage": "Акумуляторне зберігання",
@ -927,7 +921,6 @@
"waitForVehicle": "Готовий. Очікування обігрівача…"
},
"hemsWarning": {
"description": "Зменшено плату до не більше {limit}.",
"title": "Зовнішнє обмеження:"
},
"loadpoint": {

View file

@ -115,7 +115,6 @@
"feedinPrice": "上网电价",
"gridPrice": "电网电价",
"heaterTempLimit": "加热器限制",
"hemsType": "系统",
"identifier": "RFID 标识符",
"no": "否",
"odometer": "里程表读数",

View file

@ -9,7 +9,6 @@ import (
"github.com/enbility/eebus-go/usecases/eg/lpc"
"github.com/enbility/ship-go/cert"
"github.com/enbility/spine-go/model"
"github.com/evcc-io/evcc/core/circuit"
"github.com/evcc-io/evcc/hems/eebus"
hems "github.com/evcc-io/evcc/hems/eebus"
server "github.com/evcc-io/evcc/server/eebus"
@ -50,10 +49,7 @@ func TestEEBus(t *testing.T) {
eventC := make(chan api.EventType, 1)
box.remoteEventC = eventC
gridcontrol, err := circuit.New(util.NewLogger("gridcontrol"), "gridcontrol", 0, 0, nil, time.Minute)
require.NoError(t, err)
hems, err := hems.NewEEBus(t.Context(), box.ski, eebus.Limits{}, nil, gridcontrol, time.Second)
hems, err := hems.NewEEBus(t.Context(), box.ski, eebus.Limits{}, nil, nil, time.Second)
require.NoError(t, err, "hems")
go hems.Run()

View file

@ -377,8 +377,10 @@ func testInstance(instance any) map[string]testResult {
}
if dev, ok := api.Cap[api.Curtailer](instance); ok {
val, err := dev.Curtailed()
makeResult("curtailed", val, err)
makeResult("curtailable", true, nil)
if val, err := dev.Curtailed(); err != nil || val {
makeResult("curtailed", true, err)
}
}
if dev, ok := api.Cap[api.Identifier](instance); ok {

View file

@ -17,6 +17,13 @@ params:
en: Power
unit: W
type: int
- name: curtailable
description:
de: Abregelbar
en: Curtailable
type: bool
advanced: true
usages: ["pv"]
- name: energy
description:
de: Zählerstand
@ -53,6 +60,18 @@ render: |
power:
source: const
value: {{ .power }}
{{- if .curtailable }}
curtail:
source: js
vm: shared
script: |
demoMeterCurtailed = curtail;
curtailed:
source: js
vm: shared
script: |
typeof demoMeterCurtailed !== "undefined" && demoMeterCurtailed
{{- end }}
{{- if .energy }}
energy:
source: const

View file

@ -25,7 +25,7 @@ test.describe("custom meter type override", async () => {
await page.waitForLoadState("networkidle");
const editor = meterModal.getByTestId("yaml-editor");
await editorClear(editor, 20);
await editorClear(editor);
await editorPaste(
editor,
page,

View file

@ -420,7 +420,7 @@ test.describe("charging loadpoint", async () => {
"status: # charger status (A: not connected, B: connected, C: charging)"
);
await editorClear(chargerEditor, 20);
await editorClear(chargerEditor);
await editorPaste(
chargerEditor,
page,
@ -459,7 +459,7 @@ power:
await meterModal.getByLabel("Manufacturer").selectOption("User-defined device");
await page.waitForLoadState("networkidle");
const meterEditor = meterModal.getByTestId("yaml-editor");
await editorClear(meterEditor, 20);
await editorClear(meterEditor);
await editorPaste(
meterEditor,
page,
@ -577,7 +577,7 @@ test.describe("heating loadpoint", async () => {
await modal.getByLabel("Manufacturer").selectOption("User-defined heat pump");
const editor = modal.getByTestId("yaml-editor");
await editorClear(editor, 20);
await editorClear(editor);
await editorPaste(
editor,
page,

View file

@ -3,9 +3,17 @@ interval: 0.1s
site:
meters:
grid: grid
pv:
- pv
meters:
- name: grid
type: template
template: demo-meter
power: 0
- name: pv
type: template
template: demo-meter
usage: pv
power: 0
curtailable: true

View file

@ -1,4 +1,5 @@
import { test, expect } from "@playwright/test";
import axios from "axios";
import { start, stop, restart, baseUrl } from "./evcc";
import {
expectModalVisible,
@ -93,22 +94,20 @@ limit:
await restart(GRID_CONFIG);
await page.goto("/#/config");
// verify external control is the only circuit visible
await expect(page.getByTestId("circuits")).toContainText(
["External Limit", "Power", "0.0 kW"].join("")
);
// no circuits configured, no synthesized external circuit
await expect(page.getByTestId("circuits")).toContainText(["Configured", "no"].join(""));
await expect(page.getByTestId("circuits").getByTestId("device-banner")).not.toBeVisible();
// enable hems in simulator
await page.goto(simulatorUrl());
const hemsRelayCheckbox = page.getByLabel("Relay Limit");
const hemsRelayCheckbox = page.getByLabel("Relay (dim)");
await hemsRelayCheckbox.check();
await simulatorApply(page);
// verify config ui
// verify config ui: limit shown on hems card, no hint without circuits
await page.goto("/#/config");
await expect(page.getByTestId("circuits")).toContainText(
["External Limit", "Consumption limited", "yes", "Power", "0.0 kW / 4.2 kW"].join("")
);
await expect(page.getByTestId("hems")).toContainText(["Consumption limit", "4.2 kW"].join(""));
await expect(page.getByTestId("circuits").getByTestId("device-banner")).not.toBeVisible();
// disable in simulator
await page.goto(simulatorUrl());
@ -118,6 +117,149 @@ limit:
await stopSimulator();
});
test("fnn signals", async ({ page }) => {
const GRID_CONFIG = "hems-grid.evcc.yaml";
await startSimulator();
await start(GRID_CONFIG);
await page.goto("/#/config");
// configure circuits, hint on the circuits card requires them
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 fnn hems with all signals wired to the simulator
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);
const signalSource = (signal: string) => `
source: http
uri: ${simulatorUrl()}/api/state
jq: .hems.${signal}`;
await editorPaste(
hemsEditor,
page,
`type: fnn
maxDimPower: 4200
maxCurtailPower: 10000
interval: 0.1s
w3:${signalSource("w3")}
s1:${signalSource("s1")}
s2:${signalSource("s2")}
w4:${signalSource("w4")}`
);
await hemsModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(hemsModal);
await restart(GRID_CONFIG);
// drive signals via simulator api, switching between both uis is too slow
const setSignal = async (signal: string, active: boolean) => {
const { data: state } = await axios.get(`${simulatorUrl()}/api/state`);
state.hems[signal] = active;
await axios.post(`${simulatorUrl()}/api/state`, state);
};
const hems = page.getByTestId("hems");
const hint = page.getByTestId("circuits").getByTestId("device-banner");
const pvBanner = page.getByTestId("pv").getByTestId("device-banner");
// no signals: no limits
await page.goto("/#/config");
await expect(hems).toContainText(
["Consumption limited", "no", "Feed-in limited", "no"].join("")
);
await expect(hint).not.toBeVisible();
// dim (W4): consumption limited
await setSignal("w4", true);
await expect(hems).toContainText(["Consumption limit", "4.2 kW"].join(""));
await expect(hint).toHaveText("Consumption limited");
await expect(pvBanner).not.toBeVisible();
// main screen shows the warning
await page.goto("/#/");
await expect(page.getByTestId("hems-warning")).toContainText("4.2 kW");
await page.goto("/#/config");
// full curtailment (W3): feed-in limited to 0
await setSignal("w4", false);
await setSignal("w3", true);
await expect(hems).toContainText(["Feed-in limit", "0.0 kW"].join(""));
await expect(hint).not.toBeVisible();
// device status is polled, reload for a fresh value
await page.reload();
await expect(pvBanner).toHaveText("Production limited");
// partial curtailment (S1): 60% of 10 kW
await setSignal("w3", false);
await setSignal("s1", true);
await expect(hems).toContainText(["Feed-in limit", "6.0 kW"].join(""));
// main screen shows the production warning
await page.goto("/#/");
await expect(page.getByTestId("hems-warning")).toContainText(["Feed-in", "≤ 6.0 kW"].join(""));
await expect(page.getByTestId("hems-warning")).not.toContainText("Consumption");
await page.goto("/#/config");
// partial curtailment (S2): 30% of 10 kW
await setSignal("s1", false);
await setSignal("s2", true);
await expect(hems).toContainText(["Feed-in limit", "3.0 kW"].join(""));
// all clear: banners disappear
await setSignal("s2", false);
await expect(hint).not.toBeVisible();
await page.reload();
await expect(pvBanner).not.toBeVisible();
await expect(hems).toContainText(
["Consumption limited", "no", "Feed-in limited", "no"].join("")
);
await stopSimulator();
});
test("curtailment without circuits", async ({ page }) => {
const GRID_CONFIG = "hems-grid.evcc.yaml";
await start(GRID_CONFIG);
await page.goto("/#/config");
// configure hems with constant curtailment, no circuits
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: fnn
maxCurtailPower: 10000
interval: 0.1s
w3:
source: const
value: true`
);
await hemsModal.getByRole("button", { name: "Save" }).click();
await expectModalHidden(hemsModal);
await restart(GRID_CONFIG);
await page.goto("/#/config");
await expect(page.getByTestId("hems")).toContainText(["Feed-in limit", "0.0 kW"].join(""));
await expect(page.getByTestId("pv").getByTestId("device-banner")).toHaveText(
"Production limited"
);
});
test.describe("grid sessions CSV in app context", () => {
test("dispatches download event", async ({ page }) => {
await enableAppContext(page);
@ -175,23 +317,14 @@ limit:
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("")
// circuit tree shows only the user circuit, consumption limit as hint above it
await expect(page.getByTestId("circuits").getByTestId("device-banner")).toHaveText(
"Consumption limited"
);
await expect(page.getByTestId("circuits")).toContainText(["House", "Power", "0.0 kW"].join(""));
await expect(page.getByTestId("circuits")).not.toContainText("External Limit");
// a new loadpoint can only be assigned to the dedicated circuit, not gridcontrol
// a new loadpoint can only be assigned to the dedicated circuit
await newLoadpoint(page, "Carport");
await addDemoCharger(page);
const lpModal = page.getByTestId("loadpoint-modal");

View file

@ -14,7 +14,7 @@ let state = {
},
loadpoints: [{ power: 0, energy: 0, enabled: false, status: "A" }],
vehicles: [{ soc: 0, range: 0 }],
hems: { relay: false },
hems: { relay: false, w3: false, s1: false, s2: false, w4: false },
ocpp: {
clients: [] as { stationId: string; serverUrl: string; connected: boolean }[],
},

View file

@ -254,21 +254,24 @@
</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>
<template v-for="(signals, group) in hemsGroups" :key="group">
<h5 class="my-3">{{ group }}</h5>
<div v-for="(label, signal) in signals" :key="signal" class="row">
<label :for="`hems-${signal}`" class="col-sm-6 col-form-label">{{ label }}</label>
<div class="col-sm-6 mb-3">
<div class="form-check form-switch">
<input
:id="`hems-${signal}`"
v-model="state.hems[signal]"
class="form-check-input"
type="checkbox"
role="switch"
/>
<label class="form-check-label" :for="`hems-${signal}`"> active </label>
</div>
</div>
</div>
</div>
</template>
<h4 class="my-4">OCPP <small>work in progress, connect only</small></h4>
@ -428,6 +431,17 @@ export default defineComponent({
mockLoginMode: false,
mockLoginState: "",
mockLoginRedirectUri: "",
hemsGroups: {
Relay: {
relay: "Relay (dim)",
},
"FNN Steuerbox": {
w4: "FNN W4 (dim)",
w3: "FNN W3 (curtail 0%)",
s2: "FNN S2 (curtail 30%)",
s1: "FNN S1 (curtail 60%)",
},
} as const,
state: null as {
site: {
grid: { power: number };
@ -441,7 +455,7 @@ export default defineComponent({
status: string;
}[];
vehicles: { soc: number; range: number }[];
hems: { relay: boolean };
hems: { relay: boolean; w3: boolean; s1: boolean; s2: boolean; w4: boolean };
ocpp: {
clients: { stationId: string; serverUrl: string; connected: boolean }[];
};

View file

@ -17,12 +17,20 @@ export async function expectModalHidden(modal: Locator): Promise<void> {
await expect(modal).toHaveAttribute("aria-hidden", "true");
}
export async function editorClear(editor: Locator, iterations = 10): Promise<void> {
for (let i = 0; i < iterations; i++) {
await editor.locator(".view-line").nth(0).click();
export async function editorClear(editor: Locator): Promise<void> {
const firstLine = editor.locator(".view-line").nth(0);
const content = editor.locator(".view-lines");
// wait for the async content load, otherwise clearing races the arriving text
await expect(firstLine).not.toHaveText("");
// retry until the editor is verified empty, keystrokes are dropped while monaco initializes
const deadline = Date.now() + 10_000;
do {
await firstLine.click();
await editor.page().keyboard.press("ControlOrMeta+KeyA", { delay: 50 });
await editor.page().keyboard.press("Backspace", { delay: 50 });
}
if ((await content.textContent())?.trim() === "") return;
} while (Date.now() < deadline);
await expect(content, "editor should be empty after clearing").toHaveText("");
}
export async function editorPaste(editor: Locator, page: Page, text: string): Promise<void> {