+
-
-
-
-
-
-
Loading ...
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
Loading ...
+
+
+
-
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
@@ -170,6 +188,7 @@ import GenericModal from "../Helper/GenericModal.vue";
import Markdown from "./Markdown.vue";
import PropertyField from "./PropertyField.vue";
const initialValues = { type: "template", deviceTitle: "", deviceIcon: "" };
+import { ICONS } from "../VehicleIcon/VehicleIcon.vue";
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -177,6 +196,11 @@ function sleep(ms) {
const CUSTOM_FIELDS = ["usage", "modbus"];
+const defaultIcons = {
+ aux: "aux",
+ ext: "meter",
+};
+
export default {
name: "MeterModal",
components: {
@@ -195,6 +219,10 @@ export default {
id: Number,
name: String,
type: String,
+ typeChoices: {
+ type: Array,
+ default: () => ["pv", "battery", "aux", "ext"],
+ },
fade: String,
},
emits: ["added", "updated", "removed", "close"],
@@ -208,6 +236,7 @@ export default {
saving: false,
selectedType: null,
loadingTemplate: false,
+ iconChoices: ICONS,
values: { ...initialValues },
};
},
@@ -226,7 +255,10 @@ export default {
return this.type || this.selectedType;
},
hasDeviceTitle() {
- return ["pv", "battery"].includes(this.meterType);
+ return ["pv", "battery", "aux", "ext"].includes(this.meterType);
+ },
+ hasDeviceIcon() {
+ return ["aux", "ext"].includes(this.meterType);
},
templateOptions() {
return this.products.filter((p) => p.group !== "generic");
@@ -299,8 +331,9 @@ export default {
}
}
},
- meterType() {
+ meterType(type) {
this.loadProducts();
+ this.values.deviceIcon = defaultIcons[type] || "";
},
templateName() {
this.loadTemplate();
@@ -314,7 +347,9 @@ export default {
},
methods: {
reset(keepTitle = false) {
- const keep = keepTitle ? { deviceTitle: this.values.deviceTitle } : {};
+ const keep = keepTitle
+ ? { deviceTitle: this.values.deviceTitle, deviceIcon: this.values.deviceIcon }
+ : {};
this.values = { ...initialValues, ...keep };
this.resetTest();
},
diff --git a/assets/js/components/VehicleIcon/Aux.vue b/assets/js/components/VehicleIcon/Aux.vue
new file mode 100644
index 000000000..39da377f4
--- /dev/null
+++ b/assets/js/components/VehicleIcon/Aux.vue
@@ -0,0 +1,14 @@
+
+
+
+
+
diff --git a/assets/js/components/VehicleIcon/Meter.vue b/assets/js/components/VehicleIcon/Meter.vue
index 89758a3f3..f2d099ebb 100644
--- a/assets/js/components/VehicleIcon/Meter.vue
+++ b/assets/js/components/VehicleIcon/Meter.vue
@@ -2,7 +2,7 @@
diff --git a/assets/js/components/VehicleIcon/VehicleIcon.stories.js b/assets/js/components/VehicleIcon/VehicleIcon.stories.js
index 88e76e9d3..685243b45 100644
--- a/assets/js/components/VehicleIcon/VehicleIcon.stories.js
+++ b/assets/js/components/VehicleIcon/VehicleIcon.stories.js
@@ -2,6 +2,7 @@ import VehicleIcon from "./VehicleIcon.vue";
const icons = [
"car",
+ "aux",
"bike",
"bus",
"moped",
diff --git a/assets/js/components/VehicleIcon/VehicleIcon.vue b/assets/js/components/VehicleIcon/VehicleIcon.vue
index 004b470fe..b5e2dd342 100644
--- a/assets/js/components/VehicleIcon/VehicleIcon.vue
+++ b/assets/js/components/VehicleIcon/VehicleIcon.vue
@@ -8,6 +8,7 @@ import "@h2d2/shopicons/es/regular/car3";
import MultiIcon from "../MultiIcon";
import airpurifier from "./Airpurifier.vue";
+import aux from "./Aux.vue";
import battery from "./Battery.vue";
import bike from "./Bike.vue";
import bulb from "./Bulb.vue";
@@ -47,6 +48,7 @@ import waterheater from "./WaterHeater.vue";
const icons = {
airpurifier,
+ aux,
battery,
bike,
bulb,
@@ -86,6 +88,8 @@ const icons = {
waterheater,
};
+export const ICONS = Object.keys(icons);
+
export default {
name: "VehicleIcon",
components: { MultiIcon },
diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue
index 7a6378def..0a70c12bc 100644
--- a/assets/js/views/Config.vue
+++ b/assets/js/views/Config.vue
@@ -17,10 +17,8 @@
Missing features
- - aux meters
- custom/plugin meters and vehicles
- - migration for vehicles, chargers, meters, loadpoints
- - remove mixed mode (evcc.yaml + db) for meters and vehicles
+ - migration for loadpoints
Migration and repair. Run evcc migrate to copy
@@ -178,7 +176,32 @@
+
+
+
{{ $t("config.section.additionalMeter") }} 🧪
+
@@ -320,6 +343,7 @@
:id="selectedMeterId"
:name="selectedMeterName"
:type="selectedMeterType"
+ :typeChoices="selectedMeterTypeChoices"
:fade="loadpointSubModalOpen ? 'right' : ''"
@added="addMeter"
@updated="meterChanged"
@@ -441,6 +465,7 @@ export default {
selectedVehicleId: undefined,
selectedMeterId: undefined,
selectedMeterType: undefined,
+ selectedMeterTypeChoices: [],
selectedChargerId: undefined,
selectedLoadpointId: undefined,
loadpointSubModalOpen: false,
@@ -470,6 +495,14 @@ export default {
const names = this.site?.battery;
return this.getMetersByNames(names);
},
+ auxMeters() {
+ const names = this.site?.aux;
+ return this.getMetersByNames(names);
+ },
+ extMeters() {
+ const names = this.site?.ext;
+ return this.getMetersByNames(names);
+ },
selectedMeterName() {
return this.getMeterById(this.selectedMeterId)?.name;
},
@@ -675,6 +708,18 @@ export default {
this.selectedMeterType = type;
this.$nextTick(() => this.meterModal().show());
},
+ addSolarBatteryMeter() {
+ this.selectedMeterId = undefined;
+ this.selectedMeterType = undefined;
+ this.selectedMeterTypeChoices = ["pv", "battery"];
+ this.$nextTick(() => this.meterModal().show());
+ },
+ newAdditionalMeter() {
+ this.selectedMeterId = undefined;
+ this.selectedMeterType = undefined;
+ this.selectedMeterTypeChoices = ["aux", "ext"];
+ this.$nextTick(() => this.meterModal().show());
+ },
editCharger(id) {
this.selectedChargerId = id;
this.$nextTick(() => this.chargerModal().show());
@@ -746,18 +791,14 @@ export default {
this.saveSite(type);
}
},
- removeMeter(type, name) {
+ removeMeter(type) {
if (type === "charge") {
// update loadpoint
this.$refs.loadpointModal?.setMeter(undefined);
- } else if (type === "grid") {
- // update site grid
- this.site.grid = "";
- this.saveSite(type);
} else {
- // update site pv, battery, aux
- this.site[type] = this.site[type].filter((i) => i !== name);
- this.saveSite(type);
+ // update site grid, pv, battery, aux, ext
+ this.loadSite();
+ this.loadDirty();
}
},
addCharger(name) {
diff --git a/cmd/root.go b/cmd/root.go
index 95e8c647a..61d5ca7ac 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -300,7 +300,7 @@ func runRoot(cmd *cobra.Command, args []string) {
auth.Disable()
}
- httpd.RegisterSystemHandler(valueChan, cache, auth, func() {
+ httpd.RegisterSystemHandler(site, valueChan, cache, auth, func() {
log.INFO.Println("evcc was stopped by user. OS should restart the service. Or restart manually.")
err = errors.New("restart required") // https://gokrazy.org/development/process-interface/
once.Do(func() { close(stopC) }) // signal loop to end
diff --git a/cmd/setup.go b/cmd/setup.go
index 21bafef5f..968442f7e 100644
--- a/cmd/setup.go
+++ b/cmd/setup.go
@@ -263,14 +263,14 @@ func configureMeters(static []config.Named, names ...string) error {
instance, err := meter.NewFromConfig(ctx, cc.Type, cc.Other)
if err != nil {
- return &DeviceError{cc.Name, fmt.Errorf("cannot create meter '%s': %w", cc.Name, err)}
+ err = &DeviceError{cc.Name, fmt.Errorf("cannot create meter '%s': %w", cc.Name, err)}
}
- if err := config.Meters().Add(config.NewConfigurableDevice(&conf, instance)); err != nil {
- return &DeviceError{cc.Name, err}
+ if e := config.Meters().Add(config.NewConfigurableDevice(&conf, instance)); e != nil && err == nil {
+ err = &DeviceError{cc.Name, e}
}
- return nil
+ return err
})
}
@@ -327,14 +327,14 @@ func configureChargers(static []config.Named, names ...string) error {
instance, err := charger.NewFromConfig(ctx, cc.Type, cc.Other)
if err != nil {
- return fmt.Errorf("cannot create charger '%s': %w", cc.Name, err)
+ err = &DeviceError{cc.Name, fmt.Errorf("cannot create charger '%s': %w", cc.Name, err)}
}
- if err := config.Chargers().Add(config.NewConfigurableDevice(&conf, instance)); err != nil {
- return &DeviceError{cc.Name, err}
+ if e := config.Chargers().Add(config.NewConfigurableDevice(&conf, instance)); e != nil && err == nil {
+ err = &DeviceError{cc.Name, e}
}
- return nil
+ return err
})
}
diff --git a/core/site/api.go b/core/site/api.go
index f78d27aa2..10c52ac61 100644
--- a/core/site/api.go
+++ b/core/site/api.go
@@ -22,6 +22,10 @@ type API interface {
SetPVMeterRefs([]string)
GetBatteryMeterRefs() []string
SetBatteryMeterRefs([]string)
+ GetAuxMeterRefs() []string
+ SetAuxMeterRefs([]string)
+ GetExtMeterRefs() []string
+ SetExtMeterRefs([]string)
// circuits
GetCircuit() api.Circuit
diff --git a/core/site_api.go b/core/site_api.go
index bf0b8beaf..da033413f 100644
--- a/core/site_api.go
+++ b/core/site_api.go
@@ -116,6 +116,22 @@ func (site *Site) SetAuxMeterRefs(ref []string) {
settings.SetString(keys.AuxMeters, strings.Join(filterConfigurable(ref), ","))
}
+// GetExtMeterRefs returns the ExtMeterRef
+func (site *Site) GetExtMeterRefs() []string {
+ site.RLock()
+ defer site.RUnlock()
+ return site.Meters.ExtMetersRef
+}
+
+// SetExtMeterRefs sets the ExtMeterRef
+func (site *Site) SetExtMeterRefs(ref []string) {
+ site.Lock()
+ defer site.Unlock()
+
+ site.Meters.ExtMetersRef = ref
+ settings.SetString(keys.ExtMeters, strings.Join(filterConfigurable(ref), ","))
+}
+
// Loadpoints returns the loadpoints as api interfaces
func (site *Site) Loadpoints() []loadpoint.API {
return lo.Map(site.loadpoints, func(lp *Loadpoint, _ int) loadpoint.API { return lp })
diff --git a/i18n/de.toml b/i18n/de.toml
index 1f18c255d..70aea2609 100644
--- a/i18n/de.toml
+++ b/i18n/de.toml
@@ -23,6 +23,11 @@ never = "nur mit genug PV-Überschuss."
[config]
+[config.aux]
+description = "Gerät, das seinen Verbrauch basierend auf verfügbarem Überschuss (z. B. smarter Heizstab) selbstständig anpasst. evcc erwartet, dass dieses Gerät selbstständig seine Leistungsaufnahme reduziert, wenn es benötigt wird."
+titleAdd = "Intelligenten Verbraucher hinzufügen"
+titleEdit = "Intelligenten Verbraucher bearbeiten"
+
[config.battery]
titleAdd = "Batterie hinzufügen"
titleEdit = "Batterie bearbeiten"
@@ -105,6 +110,11 @@ C = "C (lädt)"
description = "Grundkonfiguration für die Kommunikation mit anderen EEBus-Geräten."
title = "EEBus"
+[config.ext]
+description = "Kann für Lastmanagement oder Statistikzwecke verwendet werden."
+titleAdd = "Zusätzlichen Zähler hinzufügen"
+titleEdit = "Zusätzlichen Zähler bearbeiten"
+
[config.form]
deprecated = "veraltet"
example = "Beispiel"
@@ -252,6 +262,12 @@ template = "Hersteller"
titleChoice = "Was möchtest du hinzufügen?"
validateSave = "Überprüfen & speichern"
+[config.meter.option]
+aux = "Intelligenten Verbraucher hinzufügen"
+battery = "Hausbatterie hinzufügen"
+ext = "Zusätzlichen Zähler hinzufügen"
+pv = "PV-Anlage hinzufügen"
+
[config.modbusproxy]
description = "Erlaubt mehreren Clients den Zugriff auf ein einzelnes Modbus-Gerät."
title = "Modbus-Proxy"
@@ -304,10 +320,11 @@ B = "B (verbunden)"
C = "C (lädt)"
[config.pv]
-titleAdd = "Zähler (PV) hinzufügen"
-titleEdit = "Zähler (PV) bearbeiten"
+titleAdd = "PV-Anlage hinzufügen"
+titleEdit = "PV-Anlage bearbeiten"
[config.section]
+additionalMeter = "Zusätzliche Zähler"
general = "Allgemein"
grid = "Netzanschluss"
integrations = "Integrationen"
diff --git a/i18n/en.toml b/i18n/en.toml
index 2a726150a..81688f61b 100644
--- a/i18n/en.toml
+++ b/i18n/en.toml
@@ -23,6 +23,11 @@ never = "only with enough surplus."
[config]
+[config.aux]
+description = "Device that adjusts its consumption based on available surplus (like smart water heaters). evcc expects that this devices reduces its power consumption if needed."
+titleAdd = "Add Self-Regulating Consumer"
+titleEdit = "Edit Self-Regulating Consumer"
+
[config.battery]
titleAdd = "Add Battery"
titleEdit = "Edit Battery"
@@ -105,6 +110,11 @@ C = "C (charging)"
description = "Configuration that enables evcc to communicate with other EEBus devices."
title = "EEBus"
+[config.ext]
+description = "Can be used for load management or statistics purposes."
+titleAdd = "Add External Meter"
+titleEdit = "Edit External Meter"
+
[config.form]
deprecated = "deprecated"
example = "Example"
@@ -227,6 +237,7 @@ vehicleLabel = "Default vehicle"
vehiclesTitle = "Vehicles"
[config.main]
+addAdditional = "Add additional meter"
addGrid = "Add grid meter"
addLoadpoint = "Add charge point"
addPvBattery = "Add solar or battery"
@@ -252,6 +263,12 @@ template = "Manufacturer"
titleChoice = "What Do You Want To Add?"
validateSave = "Validate & save"
+[config.meter.option]
+aux = "Add self-regulating consumer"
+battery = "Add battery meter"
+ext = "Add external meter"
+pv = "Add solar meter"
+
[config.modbusproxy]
description = "Allow multiple clients to access a single Modbus device."
title = "Modbus Proxy"
@@ -307,6 +324,7 @@ titleAdd = "Add Solar Meter"
titleEdit = "Edit Solar Meter"
[config.section]
+additionalMeter = "Additional meters"
general = "General"
grid = "Grid"
integrations = "Integrations"
diff --git a/server/http.go b/server/http.go
index 543bd9455..468353b21 100644
--- a/server/http.go
+++ b/server/http.go
@@ -7,6 +7,7 @@ import (
eapi "github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/api/globalconfig"
+ "github.com/evcc-io/evcc/core"
"github.com/evcc-io/evcc/core/keys"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/server/assets"
@@ -123,19 +124,6 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
}
- // config ui (secured)
- configApi := api.PathPrefix("/config").Subrouter()
-
- // TODO clarify location of site config
- configRoutes := map[string]route{
- "site": {"GET", "/site", siteHandler(site)},
- "updatesite": {"PUT", "/site", updateSiteHandler(site)},
- }
-
- for _, r := range configRoutes {
- configApi.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
- }
-
// vehicle api
vehicles := map[string]route{
"minsoc": {"POST", "/vehicles/{name:[a-zA-Z0-9_.:-]+}/minsoc/{value:[0-9]+}", minSocHandler(site)},
@@ -193,7 +181,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
}
// RegisterSystemHandler provides system level handlers
-func (s *HTTPd) RegisterSystemHandler(valueChan chan<- util.Param, cache *util.ParamCache, auth auth.Auth, shutdown func()) {
+func (s *HTTPd) RegisterSystemHandler(site *core.Site, valueChan chan<- util.Param, cache *util.ParamCache, auth auth.Auth, shutdown func()) {
router := s.Server.Handler.(*mux.Router)
// api
@@ -204,6 +192,17 @@ func (s *HTTPd) RegisterSystemHandler(valueChan chan<- util.Param, cache *util.P
handlers.AllowedHeaders([]string{"Content-Type"}),
))
+ if site == nil {
+ // If site is nil, create a new empty site. Settings will be loaded during this process and
+ // site meter references and title can be updated using APIs.
+ var err error
+ site, err = core.NewSiteFromConfig(nil)
+ if err != nil {
+ // should not happen
+ panic(err)
+ }
+ }
+
{ // /api
routes := map[string]route{
"state": {"GET", "/state", stateHandler(cache)},
@@ -243,7 +242,7 @@ func (s *HTTPd) RegisterSystemHandler(valueChan chan<- util.Param, cache *util.P
"dirty": {"GET", "/dirty", getHandler(ConfigDirty)},
"newdevice": {"POST", "/devices/{class:[a-z]+}", newDeviceHandler},
"updatedevice": {"PUT", "/devices/{class:[a-z]+}/{id:[0-9.]+}", updateDeviceHandler},
- "deletedevice": {"DELETE", "/devices/{class:[a-z]+}/{id:[0-9.]+}", deleteDeviceHandler},
+ "deletedevice": {"DELETE", "/devices/{class:[a-z]+}/{id:[0-9.]+}", deleteDeviceHandler(site)},
"testconfig": {"POST", "/test/{class:[a-z]+}", testConfigHandler},
"testmerged": {"POST", "/test/{class:[a-z]+}/merge/{id:[0-9.]+}", testConfigHandler},
"interval": {"POST", "/interval/{value:[0-9.]+}", settingsSetDurationHandler(keys.Interval)},
@@ -281,6 +280,14 @@ func (s *HTTPd) RegisterSystemHandler(valueChan chan<- util.Param, cache *util.P
api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
}
+ // site
+ for _, r := range map[string]route{
+ "site": {"GET", "/site", siteHandler(site)},
+ "updatesite": {"PUT", "/site", updateSiteHandler(site)},
+ } {
+ api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc)
+ }
+
// loadpoints
for _, r := range map[string]route{
"loadpoints": {"GET", "/loadpoints", loadpointsConfigHandler()},
diff --git a/server/http_config_device_handler.go b/server/http_config_device_handler.go
index 71a6e731e..4a41ea306 100644
--- a/server/http_config_device_handler.go
+++ b/server/http_config_device_handler.go
@@ -12,6 +12,7 @@ import (
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/charger"
"github.com/evcc-io/evcc/core/circuit"
+ "github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/meter"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
@@ -391,76 +392,110 @@ func deleteDevice[T any](id int, h config.Handler[T]) error {
return h.Delete(name)
}
+// cleanupSiteMeterRef removes a meter reference from site configuration
+func cleanupSiteMeterRef(name string, get func() []string, set func([]string)) {
+ var res []string
+ refs := get()
+ for _, ref := range refs {
+ if ref != name {
+ res = append(res, ref)
+ }
+ }
+ if len(refs) != len(res) {
+ set(res)
+ }
+}
+
// deleteDeviceHandler deletes a device from database by class
-func deleteDeviceHandler(w http.ResponseWriter, r *http.Request) {
- h := config.Loadpoints()
+func deleteDeviceHandler(site site.API) func(w http.ResponseWriter, r *http.Request) {
+ return func(w http.ResponseWriter, r *http.Request) {
+ h := config.Loadpoints()
- vars := mux.Vars(r)
+ vars := mux.Vars(r)
- class, err := templates.ClassString(vars["class"])
- if err != nil {
- jsonError(w, http.StatusBadRequest, err)
- return
- }
-
- id, err := strconv.Atoi(vars["id"])
- if err != nil {
- jsonError(w, http.StatusBadRequest, err)
- return
- }
-
- switch class {
- case templates.Charger:
- err = deleteDevice(id, config.Chargers())
-
- // cleanup references
- for _, dev := range h.Devices() {
- lp := dev.Instance()
- if lp.GetChargerRef() == config.NameForID(id) {
- lp.SetChargerRef("")
- }
+ class, err := templates.ClassString(vars["class"])
+ if err != nil {
+ jsonError(w, http.StatusBadRequest, err)
+ return
}
- case templates.Meter:
- err = deleteDevice(id, config.Meters())
-
- // cleanup references
- for _, dev := range h.Devices() {
- lp := dev.Instance()
- if lp.GetMeterRef() == config.NameForID(id) {
- lp.SetMeterRef("")
- }
+ id, err := strconv.Atoi(vars["id"])
+ if err != nil {
+ jsonError(w, http.StatusBadRequest, err)
+ return
}
- case templates.Vehicle:
- err = deleteDevice(id, config.Vehicles())
+ switch class {
+ case templates.Charger:
+ err = deleteDevice(id, config.Chargers())
- // cleanup references
- for _, dev := range h.Devices() {
- lp := dev.Instance()
- if lp.GetDefaultVehicleRef() == config.NameForID(id) {
- lp.SetDefaultVehicleRef("")
+ // cleanup references
+ for _, dev := range h.Devices() {
+ lp := dev.Instance()
+ if lp.GetChargerRef() == config.NameForID(id) {
+ lp.SetChargerRef("")
+ }
}
+
+ case templates.Meter:
+ err = deleteDevice(id, config.Meters())
+
+ // cleanup references
+ name := config.NameForID(id)
+
+ if site.GetGridMeterRef() == name {
+ site.SetGridMeterRef("")
+ }
+
+ for _, fun := range []struct {
+ get func() []string
+ set func([]string)
+ }{
+ {site.GetPVMeterRefs, site.SetPVMeterRefs},
+ {site.GetBatteryMeterRefs, site.SetBatteryMeterRefs},
+ {site.GetAuxMeterRefs, site.SetAuxMeterRefs},
+ {site.GetExtMeterRefs, site.SetExtMeterRefs},
+ } {
+ cleanupSiteMeterRef(name, fun.get, fun.set)
+ }
+
+ for _, dev := range h.Devices() {
+ lp := dev.Instance()
+ if lp.GetMeterRef() == name {
+ lp.SetMeterRef("")
+ }
+ }
+
+ case templates.Vehicle:
+ err = deleteDevice(id, config.Vehicles())
+
+ // cleanup references
+ for _, dev := range h.Devices() {
+ lp := dev.Instance()
+ if lp.GetDefaultVehicleRef() == config.NameForID(id) {
+ lp.SetDefaultVehicleRef("")
+ }
+ }
+
+ case templates.Circuit:
+ err = deleteDevice(id, config.Circuits())
}
- case templates.Circuit:
- err = deleteDevice(id, config.Circuits())
+ setConfigDirty()
+
+ if err != nil {
+ jsonError(w, http.StatusBadRequest, err)
+ return
+ }
+
+ res := struct {
+ ID int `json:"id"`
+ }{
+ ID: id,
+ }
+
+ jsonResult(w, res)
}
-
- setConfigDirty()
-
- if err != nil {
- jsonError(w, http.StatusBadRequest, err)
- return
- }
-
- res := struct {
- ID int `json:"id"`
- }{
- ID: id,
- }
-
- jsonResult(w, res)
}
func testConfig[T any](ctx context.Context, id int, class templates.Class, req configReq, newFromConf newFromConfFunc[T], h config.Handler[T]) (T, error) {
diff --git a/server/http_config_site_handler.go b/server/http_config_site_handler.go
index eb460f77b..996900d24 100644
--- a/server/http_config_site_handler.go
+++ b/server/http_config_site_handler.go
@@ -15,11 +15,15 @@ func siteHandler(site site.API) http.HandlerFunc {
Grid string `json:"grid"`
PV []string `json:"pv"`
Battery []string `json:"battery"`
+ Aux []string `json:"aux"`
+ Ext []string `json:"ext"`
}{
Title: site.GetTitle(),
Grid: site.GetGridMeterRef(),
PV: site.GetPVMeterRefs(),
Battery: site.GetBatteryMeterRefs(),
+ Aux: site.GetAuxMeterRefs(),
+ Ext: site.GetExtMeterRefs(),
}
jsonResult(w, res)
@@ -44,6 +48,8 @@ func updateSiteHandler(site site.API) http.HandlerFunc {
Grid *string
PV *[]string
Battery *[]string
+ Aux *[]string
+ Ext *[]string
}
if err := jsonDecoder(r.Body).Decode(&payload); err != nil {
@@ -82,6 +88,24 @@ func updateSiteHandler(site site.API) http.HandlerFunc {
setConfigDirty()
}
+ if payload.Aux != nil {
+ if !validateRefs(w, *payload.Aux) {
+ return
+ }
+
+ site.SetAuxMeterRefs(*payload.Aux)
+ setConfigDirty()
+ }
+
+ if payload.Ext != nil {
+ if !validateRefs(w, *payload.Ext) {
+ return
+ }
+
+ site.SetExtMeterRefs(*payload.Ext)
+ setConfigDirty()
+ }
+
status := map[bool]int{false: http.StatusOK, true: http.StatusAccepted}
w.WriteHeader(status[ConfigDirty()])
}
diff --git a/tests/config-aux.spec.js b/tests/config-aux.spec.js
new file mode 100644
index 000000000..a67008248
--- /dev/null
+++ b/tests/config-aux.spec.js
@@ -0,0 +1,60 @@
+import { test, expect } from "@playwright/test";
+import { start, stop, restart, baseUrl } from "./evcc";
+import { enableExperimental } from "./utils";
+
+const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml";
+
+test.use({ baseURL: baseUrl() });
+
+test.beforeAll(async () => {
+ await start(CONFIG_GRID_ONLY);
+});
+test.afterAll(async () => {
+ await stop();
+});
+
+test.describe("aux meter", async () => {
+ test("create and remove aux meter", async ({ page }) => {
+ await page.goto("/#/config");
+ await enableExperimental(page);
+
+ await expect(page.getByTestId("aux")).toHaveCount(0);
+
+ // create
+ await page.getByRole("button", { name: "Add additional meter" }).click();
+
+ const meterModal = page.getByTestId("meter-modal");
+ await meterModal.getByRole("button", { name: "Add self-regulating consumer" }).click();
+ await meterModal.getByLabel("Title").fill("Water heater");
+ await meterModal.getByLabel("Manufacturer").selectOption("Demo meter");
+ await meterModal.getByLabel("Power (W)").fill("1200");
+ await meterModal.getByRole("button", { name: "Validate & save" }).click();
+ await expect(meterModal).not.toBeVisible();
+
+ // check
+ await expect(page.getByTestId("aux")).toBeVisible(1);
+ await expect(page.getByTestId("aux")).toContainText("Water heater");
+ await expect(page.getByTestId("aux")).toContainText("1.2 kW");
+
+ // restart and check again
+ await restart(CONFIG_GRID_ONLY);
+ await page.reload();
+
+ // recheck
+ await expect(page.getByTestId("aux")).toBeVisible(1);
+ await expect(page.getByTestId("aux")).toContainText("Water heater");
+ await expect(page.getByTestId("aux")).toContainText("1.2 kW");
+
+ // delete
+ await page.goto("/#/config");
+ await page.getByTestId("aux").getByRole("button", { name: "edit" }).click();
+ await meterModal.getByRole("button", { name: "Delete" }).click();
+ await expect(page.getByTestId("aux")).toHaveCount(0);
+
+ // restart and check again
+ await restart(CONFIG_GRID_ONLY);
+ await page.reload();
+ await expect(page.getByTestId("aux")).toHaveCount(0);
+ await expect(page.getByTestId("fatal-error")).not.toBeVisible();
+ });
+});
diff --git a/tests/config-pv.spec.js b/tests/config-pv.spec.js
index 74825934b..27cc1dc1a 100644
--- a/tests/config-pv.spec.js
+++ b/tests/config-pv.spec.js
@@ -1,5 +1,6 @@
import { test, expect } from "@playwright/test";
import { start, stop, restart, baseUrl } from "./evcc";
+import { startSimulator, stopSimulator, simulatorHost } from "./simulator";
import { enableExperimental } from "./utils";
const CONFIG_GRID_ONLY = "config-grid-only.evcc.yaml";
@@ -66,4 +67,42 @@ test.describe("pv meter", async () => {
await page.reload();
await expect(page.getByTestId("pv")).toHaveCount(0);
});
+
+ test("remove broken pv meter", async ({ page }) => {
+ // setup test data for mock openems api
+ await startSimulator();
+
+ await page.goto("/#/config");
+ await enableExperimental(page);
+
+ // create meter
+ await page.getByRole("button", { name: "Add solar or battery" }).click();
+ const meterModal = page.getByTestId("meter-modal");
+ await meterModal.getByRole("button", { name: "Add solar meter" }).click();
+ await meterModal.getByLabel("Title").fill("North Roof");
+ await meterModal.getByLabel("Manufacturer").selectOption("shelly-1pm");
+ await meterModal.getByLabel("IP address or hostname").fill(simulatorHost());
+ await meterModal.getByRole("button", { name: "Validate & save" }).click();
+ await expect(meterModal).not.toBeVisible();
+ await expect(page.getByTestId("pv")).toBeVisible(1);
+ await expect(page.getByTestId("pv")).toContainText("North Roof");
+
+ // break meter
+ await stopSimulator();
+ await restart(CONFIG_GRID_ONLY);
+ await page.reload();
+
+ // remove meter
+ await expect(page.getByTestId("fatal-error")).toBeVisible();
+ await expect(page.getByTestId("pv")).toBeVisible(1);
+ await page.getByTestId("pv").getByRole("button", { name: "edit" }).click();
+ await meterModal.getByRole("button", { name: "Delete" }).click();
+ await expect(page.getByTestId("pv")).toHaveCount(0);
+
+ // restart and check again
+ await restart(CONFIG_GRID_ONLY);
+ await page.reload();
+ await expect(page.getByTestId("pv")).toHaveCount(0);
+ await expect(page.getByTestId("fatal-error")).not.toBeVisible();
+ });
});
diff --git a/tests/simulator/api.js b/tests/simulator/api.js
index 11e8b328d..c8e0ef8e0 100644
--- a/tests/simulator/api.js
+++ b/tests/simulator/api.js
@@ -10,6 +10,11 @@ let state = {
vehicles: [{ soc: 0, range: 0 }],
};
+const loggingMiddleware = (req, res, next) => {
+ console.log(`[simulator] ${req.method} ${req.originalUrl}`);
+ next();
+};
+
const stateApiMiddleware = (req, res, next) => {
if (req.method === "POST" && req.originalUrl === "/api/state") {
console.log("[simulator] POST /api/state");
@@ -66,16 +71,33 @@ const teslaloggerMiddleware = (req, res, next) => {
}
};
+const shellyMiddleware = (req, res, next) => {
+ // simulate a shelly gen1 device api. implement power and energy
+ if (req.method === "GET" && req.originalUrl === "/shelly") {
+ res.end(JSON.stringify({ gen: 2 }));
+ } else if (req.originalUrl === "/rpc/Shelly.GetStatus") {
+ res.end(
+ JSON.stringify({
+ "switch:0": { apower: state.site.pv.power, aenergy: { total: state.site.pv.energy } },
+ })
+ );
+ } else {
+ next();
+ }
+};
+
export default () => ({
name: "api",
enforce: "pre",
configureServer(server) {
console.log("[simulator] configured");
return () => {
+ server.middlewares.use(loggingMiddleware);
server.middlewares.use(bodyParser.json());
server.middlewares.use(stateApiMiddleware);
server.middlewares.use(openemsMiddleware);
server.middlewares.use(teslaloggerMiddleware);
+ server.middlewares.use(shellyMiddleware);
};
},
});