HEMS/§14a: visualize limit state (#23361)
This commit is contained in:
parent
5ff723dab0
commit
0744b68336
19 changed files with 319 additions and 47 deletions
|
|
@ -55,6 +55,7 @@ export default {
|
|||
switch (name) {
|
||||
case "power":
|
||||
case "solarForecast":
|
||||
case "hemsActiveLimit":
|
||||
return this.fmtW(value);
|
||||
case "energy":
|
||||
case "capacity":
|
||||
|
|
@ -94,6 +95,8 @@ export default {
|
|||
return value
|
||||
? this.$t("config.deviceValue.yes")
|
||||
: this.$t("config.deviceValue.no");
|
||||
case "hemsType":
|
||||
return this.$t(`config.deviceValueHemsType.${value}`);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@
|
|||
id="hemsModal"
|
||||
:title="$t('config.hems.title')"
|
||||
:description="$t('config.hems.description')"
|
||||
docs="/docs/reference/configuration/hems"
|
||||
docs="/docs/features/14a-enwg-steuve"
|
||||
:defaultYaml="defaultYaml"
|
||||
endpoint="/config/hems"
|
||||
removeKey="hems"
|
||||
size="md"
|
||||
data-testid="hems-modal"
|
||||
@changed="$emit('changed')"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,18 @@
|
|||
## external control via binary in put
|
||||
|
||||
#type: relay
|
||||
#maxPower: 4200 # limit loadpoints to 4.2 kW total
|
||||
#limit: # limit signal, plugin
|
||||
# source: mqtt
|
||||
# topic: hems/limit/status # 0/false = normal, 1/true = limit active
|
||||
|
||||
## external control via EEBus protocol
|
||||
|
||||
#type: eebus # general EEBus setup (cert gen) required
|
||||
#ski: "1234-5678-90AB-CDEF" # SKI of control box (grid operator)
|
||||
|
||||
## SMA Sunny Home Manager 2.0, will change soon https://github.com/evcc-io/evcc/pull/23190
|
||||
|
||||
#type: sma
|
||||
#allowcontrol: false
|
||||
#vendorid: AAAAAAAA
|
||||
|
|
|
|||
|
|
@ -173,12 +173,7 @@
|
|||
data-testid="energyflow-entry-home"
|
||||
/>
|
||||
<EnergyflowEntry
|
||||
:name="
|
||||
// @ts-ignore
|
||||
$t('main.energyflow.loadpoints', activeLoadpointsCount, {
|
||||
count: activeLoadpointsCount,
|
||||
})
|
||||
"
|
||||
:name="loadpointsLabel"
|
||||
icon="vehicle"
|
||||
:iconProps="{ names: vehicleIcons }"
|
||||
:power="loadpointsPower"
|
||||
|
|
@ -479,6 +474,12 @@ export default defineComponent({
|
|||
loadpointsExpanded() {
|
||||
return settings.energyflowLoadpoints;
|
||||
},
|
||||
loadpointsLabel() {
|
||||
// @ts-expect-error plural
|
||||
return this.$t("main.energyflow.loadpoints", this.activeLoadpointsCount, {
|
||||
count: this.activeLoadpointsCount,
|
||||
});
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
pvConfigured() {
|
||||
|
|
|
|||
25
assets/js/components/HemsWarning.vue
Normal file
25
assets/js/components/HemsWarning.vue
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<template>
|
||||
<div v-if="lpcLimit" class="alert alert-warning" data-testid="hems-warning">
|
||||
<strong>{{ $t("main.hemsWarning.title") }}</strong>
|
||||
{{ $t("main.hemsWarning.description", { limit: fmtW(lpcLimit, POWER_UNIT.KW) }) }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import { type Circuit } from "@/types/evcc";
|
||||
import formatter from "@/mixins/formatter";
|
||||
|
||||
export default defineComponent({
|
||||
name: "HemsWarning",
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
circuits: { type: Object as PropType<Record<string, Circuit>> },
|
||||
},
|
||||
computed: {
|
||||
lpcLimit(): number | null {
|
||||
return this.circuits?.["lpc"]?.maxPower || null;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
@ -19,6 +19,7 @@
|
|||
<TopNavigation v-bind="topNavigation" />
|
||||
</div>
|
||||
</div>
|
||||
<HemsWarning :circuits="circuits" />
|
||||
<Energyflow v-if="loadpoints.length > 0" v-bind="energyflow" />
|
||||
</div>
|
||||
<div class="d-flex flex-column justify-content-between content-area">
|
||||
|
|
@ -77,6 +78,7 @@ import "@h2d2/shopicons/es/regular/arrowup";
|
|||
import Navigation from "../Top/Navigation.vue";
|
||||
import Notifications from "../Top/Notifications.vue";
|
||||
import Energyflow from "../Energyflow/Energyflow.vue";
|
||||
import HemsWarning from "../HemsWarning.vue";
|
||||
import Loadpoints from "../Loadpoints/Loadpoints.vue";
|
||||
import Footer from "../Footer/Footer.vue";
|
||||
import formatter from "@/mixins/formatter";
|
||||
|
|
@ -90,6 +92,7 @@ import type {
|
|||
Forecast,
|
||||
LoadpointCompact,
|
||||
Notification,
|
||||
Circuit,
|
||||
SMART_COST_TYPE,
|
||||
Sponsor,
|
||||
FatalError,
|
||||
|
|
@ -103,6 +106,7 @@ export default defineComponent({
|
|||
Loadpoints,
|
||||
Energyflow,
|
||||
Footer,
|
||||
HemsWarning,
|
||||
Notifications,
|
||||
TopNavigation: Navigation,
|
||||
WelcomeIcons,
|
||||
|
|
@ -156,6 +160,7 @@ export default defineComponent({
|
|||
smartFeedInPriorityAvailable: Boolean,
|
||||
fatal: { type: Array as PropType<FatalError[]>, default: () => [] },
|
||||
forecast: Object as PropType<Forecast>,
|
||||
circuits: Object as PropType<Record<string, Circuit>>,
|
||||
telemetry: Boolean,
|
||||
evopt: { type: Object as PropType<EvOpt> },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -265,11 +265,11 @@
|
|||
<template #icon><CircuitsIcon /></template>
|
||||
<template #tags>
|
||||
<DeviceTags
|
||||
v-if="circuits.length == 0"
|
||||
v-if="circuitsSorted.length == 0"
|
||||
:tags="{ configured: { value: false } }"
|
||||
/>
|
||||
<template
|
||||
v-for="(circuit, idx) in circuits"
|
||||
v-for="(circuit, idx) in circuitsSorted"
|
||||
v-else
|
||||
:key="circuit.name"
|
||||
>
|
||||
|
|
@ -614,12 +614,21 @@ export default defineComponent({
|
|||
return this.vehicles.map((v) => ({ key: v.name, name: v.config?.title || v.name }));
|
||||
},
|
||||
hemsTags() {
|
||||
const result = { configured: { value: false }, hemsType: {} };
|
||||
const { type } = store.state?.hems || {};
|
||||
if (type) {
|
||||
result.configured.value = true;
|
||||
result.hemsType = { value: type };
|
||||
if (!type) {
|
||||
return { configured: { value: false } };
|
||||
}
|
||||
const result = {
|
||||
hemsType: {},
|
||||
hemsActiveLimit: { value: null as number | null },
|
||||
};
|
||||
result.hemsType = { value: type };
|
||||
const lpc = store.state?.circuits?.["lpc"];
|
||||
if (lpc) {
|
||||
const value = lpc.maxPower || null;
|
||||
result.hemsActiveLimit = { value };
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
isSponsor() {
|
||||
|
|
@ -644,6 +653,12 @@ export default defineComponent({
|
|||
authDisabled: store.state?.authDisabled || false,
|
||||
};
|
||||
},
|
||||
circuitsSorted() {
|
||||
const sortedNames = Object.keys(store.state?.circuits || {});
|
||||
return [...this.circuits].sort(
|
||||
(a, b) => sortedNames.indexOf(a.name) - sortedNames.indexOf(b.name)
|
||||
);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
offline() {
|
||||
|
|
@ -704,7 +719,15 @@ export default defineComponent({
|
|||
},
|
||||
async loadCircuits() {
|
||||
const response = await api.get("/config/devices/circuit");
|
||||
this.circuits = response.data || [];
|
||||
const circuits = response.data || [];
|
||||
// set lpc default title
|
||||
circuits.forEach((c: ConfigCircuit) => {
|
||||
if (c.name === "lpc" && !c.config?.title) {
|
||||
c.config = c.config || {};
|
||||
c.config.title = this.$t("config.hems.title");
|
||||
}
|
||||
});
|
||||
this.circuits = circuits;
|
||||
},
|
||||
async loadSite() {
|
||||
const response = await api.get("/config/site", {
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ func New(log *util.Logger, title string, maxCurrent, maxPower float64, meter api
|
|||
if maxCurrent == 0 {
|
||||
c.log.DEBUG.Printf("validation of max phase current disabled")
|
||||
} else if _, ok := meter.(api.PhaseCurrents); meter != nil && !ok {
|
||||
return nil, fmt.Errorf("meter does not support phase currents")
|
||||
return nil, errors.New("meter does not support phase currents")
|
||||
}
|
||||
|
||||
return c, nil
|
||||
|
|
@ -150,7 +150,7 @@ func (c *Circuit) setParent(parent api.Circuit) error {
|
|||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.parent != nil {
|
||||
return fmt.Errorf("circuit already has a parent")
|
||||
return errors.New("circuit already has a parent")
|
||||
}
|
||||
c.parent = parent
|
||||
if parent != nil {
|
||||
|
|
@ -161,6 +161,9 @@ func (c *Circuit) setParent(parent api.Circuit) error {
|
|||
|
||||
// Wrap wraps circuit with parent, keeping the original meter
|
||||
func (c *Circuit) Wrap(parent api.Circuit) error {
|
||||
if parent == c {
|
||||
return nil // wrap circuit with itself
|
||||
}
|
||||
if c.meter != nil {
|
||||
parent.(*Circuit).meter = c.meter
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ import (
|
|||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/core/circuit"
|
||||
"github.com/evcc-io/evcc/core/site"
|
||||
"github.com/evcc-io/evcc/hems/shared"
|
||||
"github.com/evcc-io/evcc/server/eebus"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/config"
|
||||
)
|
||||
|
||||
type EEBus struct {
|
||||
|
|
@ -32,6 +32,7 @@ type EEBus struct {
|
|||
failsafeDuration time.Duration
|
||||
|
||||
heartbeat *util.Value[struct{}]
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
type Limits struct {
|
||||
|
|
@ -46,6 +47,7 @@ func New(ctx context.Context, other map[string]interface{}, site site.API) (*EEB
|
|||
cc := struct {
|
||||
Ski string
|
||||
Limits `mapstructure:",squash"`
|
||||
Interval time.Duration
|
||||
}{
|
||||
Limits: Limits{
|
||||
ContractualConsumptionNominalMax: 24800,
|
||||
|
|
@ -53,6 +55,7 @@ func New(ctx context.Context, other map[string]interface{}, site site.API) (*EEB
|
|||
FailsafeConsumptionActivePowerLimit: 4200,
|
||||
FailsafeDurationMinimum: 2 * time.Hour,
|
||||
},
|
||||
Interval: 10 * time.Second,
|
||||
}
|
||||
|
||||
if err := util.DecodeOther(other, &cc); err != nil {
|
||||
|
|
@ -65,28 +68,23 @@ func New(ctx context.Context, other map[string]interface{}, site site.API) (*EEB
|
|||
return nil, errors.New("hems requires load management- please configure root circuit")
|
||||
}
|
||||
|
||||
// create new root circuit for LPC
|
||||
lpc, err := circuit.New(util.NewLogger("lpc"), "eebus", 0, 0, nil, time.Minute)
|
||||
// register LPC circuit if not already registered
|
||||
lpc, err := shared.GetOrCreateCircuit("lpc", "eebus")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// register LPC-Circuit for use in config, if not already registered
|
||||
if _, err := config.Circuits().ByName("lpc"); err != nil {
|
||||
_ = config.Circuits().Add(config.NewStaticDevice(config.Named{Name: "lpc"}, api.Circuit(lpc)))
|
||||
}
|
||||
|
||||
// wrap old root with new pc parent
|
||||
if err := root.Wrap(lpc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
site.SetCircuit(lpc)
|
||||
|
||||
return NewEEBus(ctx, cc.Ski, cc.Limits, lpc)
|
||||
return NewEEBus(ctx, cc.Ski, cc.Limits, lpc, cc.Interval)
|
||||
}
|
||||
|
||||
// NewEEBus creates EEBus charger
|
||||
func NewEEBus(ctx context.Context, ski string, limits Limits, root api.Circuit) (*EEBus, error) {
|
||||
func NewEEBus(ctx context.Context, ski string, limits Limits, root api.Circuit, interval time.Duration) (*EEBus, error) {
|
||||
if eebus.Instance == nil {
|
||||
return nil, errors.New("eebus not configured")
|
||||
}
|
||||
|
|
@ -97,6 +95,7 @@ func NewEEBus(ctx context.Context, ski string, limits Limits, root api.Circuit)
|
|||
uc: eebus.Instance.ControllableSystem(),
|
||||
Connector: eebus.NewConnector(),
|
||||
heartbeat: util.NewValue[struct{}](2 * time.Minute), // LPC-031
|
||||
interval: interval,
|
||||
|
||||
consumptionLimit: &ucapi.LoadLimit{
|
||||
Value: limits.ConsumptionLimit,
|
||||
|
|
@ -149,7 +148,7 @@ func NewEEBus(ctx context.Context, ski string, limits Limits, root api.Circuit)
|
|||
}
|
||||
|
||||
func (c *EEBus) Run() {
|
||||
for range time.Tick(10 * time.Second) {
|
||||
for range time.Tick(c.interval) {
|
||||
if err := c.run(); err != nil {
|
||||
c.log.ERROR.Println(err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ import (
|
|||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/core/circuit"
|
||||
"github.com/evcc-io/evcc/core/site"
|
||||
"github.com/evcc-io/evcc/hems/shared"
|
||||
"github.com/evcc-io/evcc/plugin"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/config"
|
||||
)
|
||||
|
||||
type Relay struct {
|
||||
|
|
@ -19,13 +19,17 @@ type Relay struct {
|
|||
root api.Circuit
|
||||
limit func() (bool, error)
|
||||
maxPower float64
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
// New creates an Relay HEMS from generic config
|
||||
func New(ctx context.Context, other map[string]interface{}, site site.API) (*Relay, error) {
|
||||
var cc struct {
|
||||
cc := struct {
|
||||
MaxPower float64
|
||||
Limit plugin.Config
|
||||
Interval time.Duration
|
||||
}{
|
||||
Interval: 10 * time.Second,
|
||||
}
|
||||
|
||||
if err := util.DecodeOther(other, &cc); err != nil {
|
||||
|
|
@ -38,17 +42,12 @@ func New(ctx context.Context, other map[string]interface{}, site site.API) (*Rel
|
|||
return nil, errors.New("hems requires load management- please configure root circuit")
|
||||
}
|
||||
|
||||
// create new root circuit for LPC
|
||||
lpc, err := circuit.New(util.NewLogger("lpc"), "relay", 0, 0, nil, time.Minute)
|
||||
// register LPC circuit if not already registered
|
||||
lpc, err := shared.GetOrCreateCircuit("lpc", "relay")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// register LPC-Circuit for use in config, if not already registered
|
||||
if _, err := config.Circuits().ByName("lpc"); err != nil {
|
||||
_ = config.Circuits().Add(config.NewStaticDevice(config.Named{Name: "lpc"}, api.Circuit(lpc)))
|
||||
}
|
||||
|
||||
// wrap old root with new pc parent
|
||||
if err := root.Wrap(lpc); err != nil {
|
||||
return nil, err
|
||||
|
|
@ -61,23 +60,24 @@ func New(ctx context.Context, other map[string]interface{}, site site.API) (*Rel
|
|||
return nil, err
|
||||
}
|
||||
|
||||
return NewRelay(lpc, limitG, cc.MaxPower)
|
||||
return NewRelay(lpc, limitG, cc.MaxPower, cc.Interval)
|
||||
}
|
||||
|
||||
// NewRelay creates Relay HEMS
|
||||
func NewRelay(root api.Circuit, limit func() (bool, error), maxPower float64) (*Relay, error) {
|
||||
func NewRelay(root api.Circuit, limit func() (bool, error), maxPower float64, interval time.Duration) (*Relay, error) {
|
||||
c := &Relay{
|
||||
log: util.NewLogger("relay"),
|
||||
root: root,
|
||||
maxPower: maxPower,
|
||||
limit: limit,
|
||||
interval: interval,
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Relay) Run() {
|
||||
for range time.Tick(10 * time.Second) {
|
||||
for range time.Tick(c.interval) {
|
||||
if err := c.run(); err != nil {
|
||||
c.log.ERROR.Println(err)
|
||||
}
|
||||
|
|
|
|||
30
hems/shared/helper.go
Normal file
30
hems/shared/helper.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package shared
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/core/circuit"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/config"
|
||||
)
|
||||
|
||||
// GetOrCreateCircuit returns or registers a circuit if not already registered
|
||||
func GetOrCreateCircuit(name, title string) (api.Circuit, error) {
|
||||
if dev, err := config.Circuits().ByName(name); err == nil {
|
||||
return dev.Instance(), err
|
||||
}
|
||||
|
||||
// create new circuit
|
||||
circuit, err := circuit.New(util.NewLogger(name), title, 0, 0, nil, time.Minute)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dev := config.NewStaticDevice[api.Circuit](config.Named{Name: name}, circuit)
|
||||
if err := config.Circuits().Add(dev); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return circuit, nil
|
||||
}
|
||||
14
i18n/de.json
14
i18n/de.json
|
|
@ -99,7 +99,8 @@
|
|||
"feedinPrice": "Einspeisevergütung",
|
||||
"gridPrice": "Netzpreis",
|
||||
"heaterTempLimit": "Heizungslimit",
|
||||
"hemsType": "System",
|
||||
"hemsActiveLimit": "Aktives Limit",
|
||||
"hemsType": "Kommunikation",
|
||||
"identifier": "RFID-Kennung",
|
||||
"no": "Nein",
|
||||
"odometer": "Kilometerstand",
|
||||
|
|
@ -125,6 +126,10 @@
|
|||
"B": "B (verbunden)",
|
||||
"C": "C (lädt)"
|
||||
},
|
||||
"deviceValueHemsType": {
|
||||
"eebus": "via EEBus",
|
||||
"relay": "via Relais"
|
||||
},
|
||||
"devices": {
|
||||
"auxMeter": "Smarter Verbraucher",
|
||||
"batteryStorage": "Batterie",
|
||||
|
|
@ -177,7 +182,7 @@
|
|||
"titleEdit": "Netzzähler bearbeiten"
|
||||
},
|
||||
"hems": {
|
||||
"description": "evcc mit einem anderen Heimenergiemanagementsystem verbinden.",
|
||||
"description": "Leistungsbegrenzung durch externe Systeme (bspw. Steuerboxen, § 14a EnWG). Setzt Lastmanagementkonfiguration voraus.",
|
||||
"title": "HEMS"
|
||||
},
|
||||
"icon": {
|
||||
|
|
@ -721,6 +726,7 @@
|
|||
"gridImport": "Netzbezug",
|
||||
"homePower": "Verbrauch",
|
||||
"loadpoints": "Ladepunkt | Ladepunkt | {count} Ladepunkte",
|
||||
"loadpointsLimit": "{limit} Limit",
|
||||
"noEnergy": "Keine Messwerte",
|
||||
"pv": "Solaranlage",
|
||||
"pvExport": "Einspeisung",
|
||||
|
|
@ -733,6 +739,10 @@
|
|||
"vehicleLimit": "Heizungslimit",
|
||||
"waitForVehicle": "Bereit. Warte auf Heizung …"
|
||||
},
|
||||
"hemsWarning": {
|
||||
"description": "Reduziere Ladeleistung, um {limit} nicht zu überschreiten.",
|
||||
"title": "Externe Begrenzung:"
|
||||
},
|
||||
"loadpoint": {
|
||||
"avgPrice": "⌀ Preis",
|
||||
"charged": "Geladen",
|
||||
|
|
|
|||
14
i18n/en.json
14
i18n/en.json
|
|
@ -99,7 +99,8 @@
|
|||
"feedinPrice": "Feed-in price",
|
||||
"gridPrice": "Grid price",
|
||||
"heaterTempLimit": "Heater limit",
|
||||
"hemsType": "System",
|
||||
"hemsActiveLimit": "Active limit",
|
||||
"hemsType": "Communication",
|
||||
"identifier": "RFID-Identifier",
|
||||
"no": "no",
|
||||
"odometer": "Odometer",
|
||||
|
|
@ -125,6 +126,10 @@
|
|||
"B": "B (connected)",
|
||||
"C": "C (charging)"
|
||||
},
|
||||
"deviceValueHemsType": {
|
||||
"eebus": "via EEBus",
|
||||
"relay": "via Relay"
|
||||
},
|
||||
"devices": {
|
||||
"auxMeter": "Smart consumer",
|
||||
"batteryStorage": "Battery storage",
|
||||
|
|
@ -177,7 +182,7 @@
|
|||
"titleEdit": "Edit Grid Meter"
|
||||
},
|
||||
"hems": {
|
||||
"description": "Connect evcc to another home energy management system.",
|
||||
"description": "Power limitation by external systems (e.g. EnWG 14a controllers). Requires load management configuration.",
|
||||
"title": "HEMS"
|
||||
},
|
||||
"icon": {
|
||||
|
|
@ -721,6 +726,7 @@
|
|||
"gridImport": "Grid use",
|
||||
"homePower": "Consumption",
|
||||
"loadpoints": "Charger| Charger | {count} chargers",
|
||||
"loadpointsLimit": "{limit} limit",
|
||||
"noEnergy": "No meter data",
|
||||
"pv": "Solar system",
|
||||
"pvExport": "Grid export",
|
||||
|
|
@ -733,6 +739,10 @@
|
|||
"vehicleLimit": "Heater limit",
|
||||
"waitForVehicle": "Ready. Waiting for heater…"
|
||||
},
|
||||
"hemsWarning": {
|
||||
"description": "Reduced charging to not exceed {limit}.",
|
||||
"title": "External limit:"
|
||||
},
|
||||
"loadpoint": {
|
||||
"avgPrice": "⌀ Price",
|
||||
"charged": "Charged",
|
||||
|
|
|
|||
1
tests/fast.evcc.yaml
Executable file
1
tests/fast.evcc.yaml
Executable file
|
|
@ -0,0 +1 @@
|
|||
interval: 0.1s
|
||||
115
tests/hems.spec.ts
Normal file
115
tests/hems.spec.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { start, stop, restart, baseUrl } from "./evcc";
|
||||
import {
|
||||
expectModalVisible,
|
||||
expectModalHidden,
|
||||
editorClear,
|
||||
editorPaste,
|
||||
enableExperimental,
|
||||
addDemoCharger,
|
||||
newLoadpoint,
|
||||
} from "./utils";
|
||||
import { startSimulator, stopSimulator, simulatorUrl, simulatorApply } from "./simulator";
|
||||
|
||||
test.use({ baseURL: baseUrl() });
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await startSimulator();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await stopSimulator();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await stop();
|
||||
});
|
||||
|
||||
const CONFIG = "fast.evcc.yaml";
|
||||
|
||||
test.describe("HEMS", () => {
|
||||
test("configure loadmanagement and hems via UI, verify logic", async ({ page }) => {
|
||||
await start(CONFIG);
|
||||
|
||||
await page.goto("/#/config");
|
||||
await enableExperimental(page);
|
||||
|
||||
// configure circuits
|
||||
await page.getByTestId("circuits").getByRole("button", { name: "edit" }).click();
|
||||
const circuitsModal = page.getByTestId("circuits-modal");
|
||||
await expectModalVisible(circuitsModal);
|
||||
const circuitsEditor = circuitsModal.getByTestId("yaml-editor");
|
||||
await editorClear(circuitsEditor);
|
||||
await editorPaste(circuitsEditor, page, `- name: lpc`);
|
||||
await circuitsModal.getByRole("button", { name: "Save" }).click();
|
||||
await expectModalHidden(circuitsModal);
|
||||
|
||||
// configure hems
|
||||
await page.getByTestId("hems").getByRole("button", { name: "edit" }).click();
|
||||
const hemsModal = page.getByTestId("hems-modal");
|
||||
await expectModalVisible(hemsModal);
|
||||
const hemsEditor = hemsModal.getByTestId("yaml-editor");
|
||||
await editorClear(hemsEditor);
|
||||
await editorPaste(
|
||||
hemsEditor,
|
||||
page,
|
||||
`type: relay
|
||||
maxPower: 4200
|
||||
interval: 0.1s
|
||||
limit:
|
||||
source: http
|
||||
uri: ${simulatorUrl()}/api/state
|
||||
jq: .hems.relay`
|
||||
);
|
||||
await hemsModal.getByRole("button", { name: "Save" }).click();
|
||||
await expectModalHidden(hemsModal);
|
||||
|
||||
await restart(CONFIG);
|
||||
await page.goto("/#/config");
|
||||
|
||||
// configure loadpoint
|
||||
const lpModal = page.getByTestId("loadpoint-modal");
|
||||
await newLoadpoint(page, "Carport");
|
||||
await addDemoCharger(page);
|
||||
await lpModal.getByLabel("Circuit").selectOption("lpc");
|
||||
await lpModal.getByRole("button", { name: "Save" }).click();
|
||||
await expectModalHidden(lpModal);
|
||||
await expect(page.getByTestId("loadpoint")).toContainText("Carport");
|
||||
|
||||
await restart(CONFIG);
|
||||
|
||||
// verify circuits
|
||||
await expect(page.getByTestId("circuits")).toContainText(
|
||||
["HEMS (lpc)", "Power", "0.0 kW"].join("")
|
||||
);
|
||||
|
||||
// enable hems in simulator
|
||||
await page.goto(simulatorUrl());
|
||||
const hemsRelayCheckbox = page.getByLabel("Relay Limit");
|
||||
await hemsRelayCheckbox.check();
|
||||
await simulatorApply(page);
|
||||
|
||||
// verify config ui
|
||||
await page.goto("/#/config");
|
||||
await expect(page.getByTestId("circuits")).toContainText(
|
||||
["HEMS (lpc)", "Power", "0.0 kW / 4.2 kW"].join("")
|
||||
);
|
||||
|
||||
// verify main ui
|
||||
await page.getByTestId("home-link").click();
|
||||
const hemsWarning = page.getByTestId("hems-warning");
|
||||
await expect(hemsWarning).toContainText(
|
||||
"External limit: Reduced charging to not exceed 4.2 kW."
|
||||
);
|
||||
|
||||
// disable in simulator
|
||||
await page.goto(simulatorUrl());
|
||||
await hemsRelayCheckbox.uncheck();
|
||||
await simulatorApply(page);
|
||||
|
||||
// verify main ui
|
||||
await page.goto("/");
|
||||
await expect(hemsWarning).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
@ -48,6 +48,7 @@ loadpoints:
|
|||
charger: charger
|
||||
meter: lp0meter
|
||||
mode: pv
|
||||
circuit: lpc
|
||||
soc:
|
||||
estimate: false
|
||||
vehicle: golf
|
||||
|
|
@ -99,3 +100,15 @@ tariffs:
|
|||
zones:
|
||||
- hours: 1-6
|
||||
price: 0.2
|
||||
|
||||
hems:
|
||||
type: relay
|
||||
maxPower: 4200
|
||||
interval: 0.25s
|
||||
limit:
|
||||
source: http
|
||||
uri: http://localhost:7072/api/state
|
||||
jq: .hems.relay
|
||||
|
||||
circuits:
|
||||
- name: lpc
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ let state = {
|
|||
},
|
||||
loadpoints: [{ power: 0, energy: 0, enabled: false, status: "A" }],
|
||||
vehicles: [{ soc: 0, range: 0 }],
|
||||
hems: { relay: false },
|
||||
};
|
||||
|
||||
const loggingMiddleware = (
|
||||
|
|
|
|||
|
|
@ -233,6 +233,23 @@
|
|||
<div class="text-end">
|
||||
<a class="link-primary" href="#" @click.prevent="addVehicle"> add vehicle </a>
|
||||
</div>
|
||||
|
||||
<h4 class="my-4">HEMS</h4>
|
||||
<div class="row">
|
||||
<label for="hemsRelay" class="col-sm-6 col-form-label">Relay Limit</label>
|
||||
<div class="col-sm-6 mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input
|
||||
id="hemsRelay"
|
||||
v-model="state.hems.relay"
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
/>
|
||||
<label class="form-check-label" for="hemsRelay"> active </label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 text-center fixed-bottom bg-light text-dark bg-opacity-75">
|
||||
<button type="submit" class="btn btn-primary">Apply changes</button>
|
||||
</div>
|
||||
|
|
@ -260,6 +277,7 @@ export default defineComponent({
|
|||
status: string;
|
||||
}[];
|
||||
vehicles: { soc: number; range: number }[];
|
||||
hems: { relay: boolean };
|
||||
} | null,
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ test.describe("vehicle startup error (using failing Tesla API)", async () => {
|
|||
await expect(page.getByTestId("vehicle-not-reachable-icon")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("broken vehicle: arrival enabeld", async ({ page }) => {
|
||||
test("broken vehicle: arrival enabled", async ({ page }) => {
|
||||
await expect(page.getByTestId("vehicle-name")).toHaveText("Broken Tesla");
|
||||
|
||||
await page.getByTestId("charging-plan").getByRole("button", { name: "none" }).click();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue