Refactor dim/curtail handling- split hems and circuit (BC) (#30284)
This commit is contained in:
parent
3758ce1957
commit
8106dc8b76
70 changed files with 960 additions and 671 deletions
19
api/api.go
19
api/api.go
|
|
@ -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
|
||||
|
|
|
|||
174
api/mock.go
174
api/mock.go
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"] = {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">≤ {{ 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">≤ {{ 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>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) : {};
|
||||
|
|
|
|||
24
cmd/root.go
24
cmd/root.go
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
10
core/site.go
10
core/site.go
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
144
hems/fnn/fnn.go
144
hems/fnn/fnn.go
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
15
i18n/de.json
15
i18n/de.json
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
15
i18n/en.json
15
i18n/en.json
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -115,7 +115,6 @@
|
|||
"feedinPrice": "上网电价",
|
||||
"gridPrice": "电网电价",
|
||||
"heaterTempLimit": "加热器限制",
|
||||
"hemsType": "系统",
|
||||
"identifier": "RFID 标识符",
|
||||
"no": "否",
|
||||
"odometer": "里程表读数",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 }[],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 }[];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue