From 2ea6c705d9322ed607b5704b04a6b64f046be42f Mon Sep 17 00:00:00 2001 From: Michael Geers Date: Sun, 23 Mar 2025 16:36:53 +0100 Subject: [PATCH] UI Config: add aux meters, improve reference handling (#19939) Co-authored-by: andig --- assets/js/components/Config/MeterModal.vue | 309 ++++++++++-------- assets/js/components/VehicleIcon/Aux.vue | 14 + assets/js/components/VehicleIcon/Meter.vue | 2 +- .../VehicleIcon/VehicleIcon.stories.js | 1 + .../js/components/VehicleIcon/VehicleIcon.vue | 4 + assets/js/views/Config.vue | 65 +++- cmd/root.go | 2 +- cmd/setup.go | 16 +- core/site/api.go | 4 + core/site_api.go | 16 + i18n/de.toml | 21 +- i18n/en.toml | 18 + server/http.go | 37 ++- server/http_config_device_handler.go | 151 +++++---- server/http_config_site_handler.go | 24 ++ tests/config-aux.spec.js | 60 ++++ tests/config-pv.spec.js | 39 +++ tests/simulator/api.js | 22 ++ 18 files changed, 571 insertions(+), 234 deletions(-) create mode 100644 assets/js/components/VehicleIcon/Aux.vue create mode 100644 tests/config-aux.spec.js diff --git a/assets/js/components/Config/MeterModal.vue b/assets/js/components/Config/MeterModal.vue index 86b0ff734..553e00941 100644 --- a/assets/js/components/Config/MeterModal.vue +++ b/assets/js/components/Config/MeterModal.vue @@ -9,149 +9,167 @@ >
-
- - + {{ $t(`config.${meterType}.description`) }} +

+ +
+ - - - - - -

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); }; }, });