+
+ {{ $t("settings.loadpoints.disabledDevice") }}
+
+
lp.visible);
+ const allVisible = this.loadpoints.every((lp) => lp.disabled || lp.visible);
const noOrder = this.loadpoints.every((lp) => lp.order === null);
return allVisible && noOrder;
},
visibleCount() {
- return this.loadpoints.filter((lp) => lp.visible).length;
+ return this.loadpoints.filter((lp) => lp.visible && !lp.disabled).length;
},
},
methods: {
diff --git a/assets/js/components/Site/Site.vue b/assets/js/components/Site/Site.vue
index 3deedb592..4285a3125 100644
--- a/assets/js/components/Site/Site.vue
+++ b/assets/js/components/Site/Site.vue
@@ -159,7 +159,7 @@ export default defineComponent({
return store.uiLoadpoints.value || [];
},
orderedVisibleLoadpoints() {
- return this.loadpoints.filter((lp) => lp.visible);
+ return this.loadpoints.filter((lp) => lp.visible && !lp.disabled);
},
batterySoc() {
return this.battery?.soc;
diff --git a/assets/js/configModal.ts b/assets/js/configModal.ts
index 098aed074..4422ce315 100644
--- a/assets/js/configModal.ts
+++ b/assets/js/configModal.ts
@@ -314,4 +314,9 @@ export function isTopModal(name: string): boolean {
return topModal()?.name === name;
}
+export function isNestedIn(name: string): boolean {
+ const idx = configModal.stack.findIndex((m) => m.name === name);
+ return idx >= 0 && idx < configModal.stack.length - 1;
+}
+
export default configModal;
diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts
index 1e77eb385..fbb031504 100644
--- a/assets/js/types/evcc.ts
+++ b/assets/js/types/evcc.ts
@@ -443,6 +443,7 @@ export interface Entity {
type: string;
id: number;
config: Config;
+ deviceDisable?: boolean;
}
export enum ConfigType {
@@ -484,6 +485,7 @@ export interface LoadpointThreshold {
export interface ConfigLoadpoint {
id?: number;
name?: string;
+ disable?: boolean;
charger: string;
meter: string;
vehicle: string;
@@ -603,6 +605,8 @@ export interface Loadpoint {
connected: boolean;
/** Duration since the vehicle was connected, in seconds. */
connectedDuration: number;
+ /** Loadpoint is disabled via configuration. */
+ disabled?: boolean;
/** Delay before charging stops in solar mode, in seconds. */
disableDelay: number;
/** Grid draw power above which charging stops in solar mode, in W. */
diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue
index 0b6049c4d..f897fd40e 100644
--- a/assets/js/views/Config.vue
+++ b/assets/js/views/Config.vue
@@ -33,8 +33,10 @@
:name="loadpoint.name"
:editable="!!loadpoint.id"
:error="hasDeviceError('loadpoint', loadpoint.name)"
+ :disabled="!!loadpoint.disable"
data-testid="loadpoint"
@edit="openModal('loadpoint', { id: loadpoint.id })"
+ @enable="handleDisable('loadpoint', loadpoint.id!, false)"
>
@@ -65,8 +67,10 @@
:name="vehicle.name"
:editable="vehicle.id >= 0"
:error="hasDeviceError('vehicle', vehicle.name)"
+ :disabled="!!vehicle.deviceDisable"
data-testid="vehicle"
@edit="openModal('vehicle', { id: vehicle.id })"
+ @enable="handleDisable('vehicle', vehicle.id, false)"
>
@@ -92,6 +96,7 @@
:has-error="hasDeviceError('meter', meter.name)"
:tags="deviceTags('meter', meter.name)"
@edit="(type, id) => openModal('meter', { type, id })"
+ @enable="handleDisable('meter', meter.id, false)"
/>
openModal('meter', { type, id })"
+ @enable="handleDisable('meter', meter.id, false)"
/>
openModal('meter', { type, id })"
+ @enable="handleDisable('meter', gridMeter.id, false)"
/>
openModal('meter', { type, id })"
+ @enable="handleDisable('meter', meter.id, false)"
/>
openModal('meter', { type, id })"
+ @enable="handleDisable('meter', meter.id, false)"
/>
openModal('meter', { type, id })"
+ @enable="handleDisable('meter', meter.id, false)"
/>
handleDisable('loadpoint', id, disable)"
+ />
+ handleDisable('vehicle', id, disable)"
+ />
+ handleDisable('meter', id, disable)"
/>
-
-
@@ -476,7 +500,11 @@
-
+ handleDisable('tariff', id, disable)"
+ />
@@ -537,6 +565,7 @@ import MessengerModal from "@/components/Config/Messaging/MessengerModal.vue";
import MessagingLegacyModal from "@/components/Config/Messaging/MessagingLegacyModal.vue";
import MeterModal from "../components/Config/MeterModal.vue";
import MeterCard from "../components/Config/MeterCard.vue";
+import { createDeviceUtils } from "../components/Config/DeviceModal";
import { openModal, type ModalResult } from "@/configModal";
import ModbusProxyIcon from "../components/MaterialIcon/ModbusProxy.vue";
import ModbusProxyModal from "../components/Config/ModbusProxyModal.vue";
@@ -1027,6 +1056,30 @@ export default defineComponent({
isUnconfigured(tags: DeviceTags): boolean {
return tags["configured"]?.value === false;
},
+ async handleDisable(deviceClass: DeviceType, id: number, disable: boolean) {
+ const promptKey = disable
+ ? "config.general.confirmDisable"
+ : "config.general.confirmEnable";
+ if (!window.confirm(this.$t(promptKey))) return;
+ const refresh: Partial void>> = {
+ meter: () => this.meterChanged({ action: "updated" }),
+ tariff: () => this.tariffChanged({ action: "updated" }),
+ vehicle: () => this.vehicleChanged(),
+ loadpoint: () => this.loadpointChanged(),
+ };
+ try {
+ if (deviceClass === "loadpoint") {
+ const { data } = await api.get(`config/loadpoints/${id}`);
+ await api.put(`config/loadpoints/${id}`, { ...data, disable });
+ } else {
+ await createDeviceUtils(deviceClass).disable(id, disable);
+ }
+ refresh[deviceClass]?.();
+ await this.loadDirty();
+ } catch (e) {
+ console.error("disable failed", e);
+ }
+ },
handleVisibilityChange() {
this.isPageVisible = document.visibilityState === "visible";
if (this.isPageVisible) {
@@ -1259,6 +1312,7 @@ export default defineComponent({
} as Record;
for (const type in devices) {
for (const device of devices[type as DeviceType]) {
+ if (device.deviceDisable) continue;
if (this.isComponentMounted && this.isPageVisible) {
await this.updateDeviceValue(type as DeviceType, device.name);
}
diff --git a/cmd/dump.go b/cmd/dump.go
index b21978a32..c1efddba1 100644
--- a/cmd/dump.go
+++ b/cmd/dump.go
@@ -131,7 +131,7 @@ func runDump(cmd *cobra.Command, args []string) {
d.DumpWithHeader(fmt.Sprintf("vehicle: %s", v.GetTitle()), v)
}
- for id, lpI := range site.Loadpoints() {
+ for id, lpI := range site.ActiveLoadpoints() {
lp := lpI.(*core.Loadpoint)
d.Header(fmt.Sprintf("loadpoint %d", id+1), "=")
diff --git a/cmd/setup.go b/cmd/setup.go
index d4831e240..6a77f1c24 100644
--- a/cmd/setup.go
+++ b/cmd/setup.go
@@ -52,7 +52,6 @@ import (
"github.com/gorilla/handlers"
"github.com/gorilla/mux"
"github.com/libp2p/zeroconf/v2"
- "github.com/samber/lo"
"github.com/spf13/cast"
"github.com/spf13/cobra"
vpr "github.com/spf13/viper"
@@ -343,7 +342,7 @@ func configurableInstance[T any](typ string, conf *config.Config, newFromConf ne
}()
var instance T
- if err == nil {
+ if err == nil && !conf.Disable {
instance, err = newFromConf(ctx, typ, other)
if err != nil {
err = &DeviceError{cc.Name, fmt.Errorf("cannot create %s '%s': %w", typ, cc.Name, err)}
@@ -1142,15 +1141,20 @@ func configureSolarTariffs(confs []config.Typed, deviceNames []string, target *a
if len(deviceNames) == 1 {
return configureTariff(config.Typed{}, deviceNames[0], target)
}
- tt := make([]api.Tariff, len(deviceNames))
- for i, name := range deviceNames {
+ var tt []api.Tariff
+ for _, name := range deviceNames {
dev, err := config.Tariffs().ByName(name)
if err != nil {
return fmt.Errorf("tariff device %s not found: %w", name, err)
}
- tt[i] = dev.Instance()
+ // nil instance marks disabled device
+ if instance := dev.Instance(); instance != nil {
+ tt = append(tt, instance)
+ }
+ }
+ if len(tt) > 0 {
+ *target = tariff.NewCombined(tt)
}
- *target = tariff.NewCombined(tt)
}
return nil
}
@@ -1205,9 +1209,13 @@ func configureTariffs(conf *globalconfig.Tariffs, names ...string) (*tariff.Tari
return nil
}
- instance, err := tariffInstance(cc.Name, config.Typed{Type: cc.Type, Other: cc.Other})
- if err != nil {
- return err
+ var instance api.Tariff
+ if !conf.Disable {
+ var err error
+ instance, err = tariffInstance(cc.Name, config.Typed{Type: cc.Type, Other: cc.Other})
+ if err != nil {
+ return err
+ }
}
if e := config.Tariffs().Add(config.NewConfigurableDevice(&conf, instance)); e != nil {
@@ -1329,9 +1337,12 @@ func configureSiteAndLoadpoints(conf *globalconfig.All) (*core.Site, error) {
errs = append(errs, &ClassError{ClassTariff, err})
}
- loadpoints := lo.Map(config.Loadpoints().Devices(), func(dev config.Device[loadpoint.API], _ int) *core.Loadpoint {
- return dev.Instance().(*core.Loadpoint)
- })
+ // nil entries mark disabled loadpoints- indexes stay aligned with config order
+ var loadpoints []*core.Loadpoint
+ for _, dev := range config.Loadpoints().Devices() {
+ inst, _ := dev.Instance().(*core.Loadpoint)
+ loadpoints = append(loadpoints, inst)
+ }
site, err := configureSite(conf.Site, loadpoints, tariffs)
if err != nil {
@@ -1368,7 +1379,7 @@ CONTINUE:
}
if slices.ContainsFunc(loadpoints, func(lp *core.Loadpoint) bool {
- return lp.GetCircuit() == instance
+ return lp != nil && lp.GetCircuit() == instance
}) {
continue CONTINUE
}
@@ -1446,14 +1457,19 @@ func configureLoadpoints(conf globalconfig.All) error {
return &DeviceError{cc.Name, err}
}
- instance, err := newLoadpoint(idx, cc.Name, static, func(log *util.Logger) coresettings.Settings {
- return coresettings.NewConfigSettingsAdapter(log, &conf)
- })
- if err != nil {
- err = &DeviceError{cc.Name, err}
+ var instance loadpoint.API
+ if !conf.Disable {
+ lp, e := newLoadpoint(idx, cc.Name, static, func(log *util.Logger) coresettings.Settings {
+ return coresettings.NewConfigSettingsAdapter(log, &conf)
+ })
+ if e != nil {
+ err = &DeviceError{cc.Name, e}
+ } else {
+ instance = lp
+ }
}
- dev := config.NewConfigurableDevice[loadpoint.API](&conf, instance)
+ dev := config.NewConfigurableDevice(&conf, instance)
if e := config.Loadpoints().Add(dev); e != nil && err == nil {
err = &DeviceError{cc.Name, e}
}
diff --git a/core/keys/loadpoint.go b/core/keys/loadpoint.go
index 7441055ee..1c7097993 100644
--- a/core/keys/loadpoint.go
+++ b/core/keys/loadpoint.go
@@ -4,6 +4,7 @@ const (
// loadpoint settings
Name = "name" // loadpoint name (config identifier)
Title = "title" // loadpoint title
+ Disabled = "disabled" // loadpoint disabled via config
Mode = "mode" // charge mode
DefaultMode = "defaultMode" // default charge mode
Charger = "charger" // charger ref
diff --git a/core/site.go b/core/site.go
index abe294d17..e6c4bb8aa 100644
--- a/core/site.go
+++ b/core/site.go
@@ -150,6 +150,23 @@ func NewSiteFromConfig(other map[string]any) (*Site, error) {
return site, nil
}
+func activeMeters(refs []string) ([]config.Device[api.Meter], error) {
+ var res []config.Device[api.Meter]
+ for _, ref := range refs {
+ dev, err := config.Meters().ByName(ref)
+ if err != nil {
+ return nil, err
+ }
+ if dev.Instance() == nil {
+ continue
+ }
+
+ res = append(res, dev)
+ }
+
+ return res, nil
+}
+
func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tariff.Tariffs) error {
site.loadpoints = loadpoints
site.tariffs = tariffs
@@ -180,7 +197,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
tariff := site.GetTariff(api.TariffUsagePlanner)
// give loadpoints access to vehicles and database
- for _, lp := range loadpoints {
+ for _, lp := range site.activeLoadpoints() {
lp.coordinator = coordinator.NewAdapter(lp, site.coordinator)
lp.planner = planner.New(lp.log, tariff)
@@ -211,13 +228,17 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
return err
}
- site.gridMeter = dev
+ if dev.Instance() == nil {
+ site.log.WARN.Println("missing grid meter instance")
+ } else {
+ site.gridMeter = dev
- me, err := metrics.NewCollector(metrics.Grid, site.Meters.GridMeterRef, metrics.Grid)
- if err != nil {
- return err
+ me, err := metrics.NewCollector(metrics.Grid, site.Meters.GridMeterRef, metrics.Grid)
+ if err != nil {
+ return err
+ }
+ site.collectors[site.Meters.GridMeterRef] = me
}
- site.collectors[site.Meters.GridMeterRef] = me
}
// multiple pv
@@ -226,6 +247,9 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
if err != nil {
return err
}
+ if dev.Instance() == nil {
+ continue
+ }
site.pvMeters = append(site.pvMeters, dev)
// energy collector (for history persistence and forecast scaling)
@@ -251,13 +275,13 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
site.collectors[metrics.Temperature] = tc
// multiple batteries
- for _, ref := range site.Meters.BatteryMetersRef {
- dev, err := config.Meters().ByName(ref)
- if err != nil {
- return err
- }
- site.batteryMeters = append(site.batteryMeters, dev)
-
+ mm, err := activeMeters(site.Meters.BatteryMetersRef)
+ if err != nil {
+ return err
+ }
+ site.batteryMeters = mm
+ for _, dev := range mm {
+ ref := dev.Config().Name
me, err := metrics.NewCollector(metrics.Battery, ref, deviceTitleOrName(dev))
if err != nil {
return err
@@ -266,13 +290,13 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
}
// additional meters used only for monitoring
- for _, ref := range site.Meters.ExtMetersRef {
- dev, err := config.Meters().ByName(ref)
- if err != nil {
- return err
- }
- site.extMeters = append(site.extMeters, dev)
-
+ mm, err = activeMeters(site.Meters.ExtMetersRef)
+ if err != nil {
+ return err
+ }
+ site.extMeters = mm
+ for _, dev := range mm {
+ ref := dev.Config().Name
me, err := metrics.NewCollector(metrics.Meter, ref, deviceTitleOrName(dev))
if err != nil {
return err
@@ -281,13 +305,13 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
}
// auxiliary meters (consumers)
- for _, ref := range site.Meters.AuxMetersRef {
- dev, err := config.Meters().ByName(ref)
- if err != nil {
- return err
- }
- site.auxMeters = append(site.auxMeters, dev)
-
+ mm, err = activeMeters(site.Meters.AuxMetersRef)
+ if err != nil {
+ return err
+ }
+ site.auxMeters = mm
+ for _, dev := range mm {
+ ref := dev.Config().Name
me, err := metrics.NewCollector(metrics.Consumer, ref, deviceTitleOrName(dev))
if err != nil {
return err
@@ -296,13 +320,13 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
}
// consumer meters
- for _, ref := range site.Meters.ConsumerMetersRef {
- dev, err := config.Meters().ByName(ref)
- if err != nil {
- return err
- }
- site.consumerMeters = append(site.consumerMeters, dev)
-
+ mm, err = activeMeters(site.Meters.ConsumerMetersRef)
+ if err != nil {
+ return err
+ }
+ site.consumerMeters = mm
+ for _, dev := range mm {
+ ref := dev.Config().Name
me, err := metrics.NewCollector(metrics.Consumer, ref, deviceTitleOrName(dev))
if err != nil {
return err
@@ -513,6 +537,10 @@ func (site *Site) DumpConfig() {
site.log.INFO.Printf(" solar: %s", presence[site.GetTariff(api.TariffUsageSolar) != nil])
for i, lp := range site.loadpoints {
+ if lp == nil {
+ continue
+ }
+
lp.log.INFO.Printf("loadpoint %d:", i+1)
lp.log.INFO.Printf(" mode: %s", lp.GetMode())
@@ -565,7 +593,7 @@ func (site *Site) publishLoadpoint(id int, key string, val any) {
// clearPlanLocks clears locked plan goals for all loadpoints
func (site *Site) clearPlanLocks() {
- for _, lp := range site.Loadpoints() {
+ for _, lp := range site.activeLoadpoints() {
lp.ClearPlanLock()
}
}
@@ -1065,7 +1093,7 @@ func (site *Site) updateLoadpoints(rates api.Rates) float64 {
sum float64
)
- for _, lp := range site.loadpoints {
+ for _, lp := range site.activeLoadpoints() {
wg.Go(func() {
power := lp.UpdateChargePowerAndCurrents()
site.prioritizer.UpdateChargePowerFlexibility(lp, rates)
@@ -1090,7 +1118,7 @@ func (site *Site) reservedPVPower(lp updater) float64 {
prio := lp.EffectivePriority()
var reserved float64
- for _, other := range site.loadpoints {
+ for _, other := range site.activeLoadpoints() {
if other == lp {
continue
}
@@ -1310,6 +1338,21 @@ func (site *Site) Prepare(valueChan chan<- util.Param, pushChan chan<- messenger
lpDevices := config.Loadpoints().Devices()
for id, lp := range site.loadpoints {
+ // publish name on the loadpoint's behalf — it doesn't know its own
+ if id < len(lpDevices) {
+ site.valueChan <- util.Param{Loadpoint: &id, Key: keys.Name, Val: lpDevices[id].Config().Name}
+ }
+
+ // disabled loadpoint- publish minimal placeholder to keep indexes stable
+ if lp == nil {
+ if id < len(lpDevices) {
+ title, _ := lpDevices[id].Config().Other["title"].(string)
+ site.valueChan <- util.Param{Loadpoint: &id, Key: keys.Title, Val: title}
+ }
+ site.valueChan <- util.Param{Loadpoint: &id, Key: keys.Disabled, Val: true}
+ continue
+ }
+
lpUIChan := make(chan util.Param)
lpPushChan := make(chan messenger.Event)
@@ -1327,11 +1370,6 @@ func (site *Site) Prepare(valueChan chan<- util.Param, pushChan chan<- messenger
}
}(id)
- // publish name on the loadpoint's behalf — it doesn't know its own
- if id < len(lpDevices) {
- site.valueChan <- util.Param{Loadpoint: &id, Key: keys.Name, Val: lpDevices[id].Config().Name}
- }
-
lp.Prepare(site, lpUIChan, lpPushChan, site.lpUpdateChan)
}
}
@@ -1339,15 +1377,16 @@ func (site *Site) Prepare(valueChan chan<- util.Param, pushChan chan<- messenger
// loopLoadpoints keeps iterating across loadpoints sending the next to the given channel
func (site *Site) loopLoadpoints(next chan<- updater) {
var logOnce sync.Once
+ active := site.activeLoadpoints()
for {
- if len(site.loadpoints) == 0 {
+ if len(active) == 0 {
logOnce.Do(func() {
site.log.INFO.Println("no loadpoints configured, running in meter-only mode")
})
next <- nil
} else {
- for _, lp := range site.loadpoints {
+ for _, lp := range active {
next <- lp
}
}
diff --git a/core/site/api.go b/core/site/api.go
index 373584e06..d81775ce9 100644
--- a/core/site/api.go
+++ b/core/site/api.go
@@ -1,6 +1,8 @@
package site
import (
+ "iter"
+
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/loadpoint"
)
@@ -15,6 +17,7 @@ type API interface {
Publisher
Loadpoints() []loadpoint.API
+ ActiveLoadpoints() iter.Seq2[int, loadpoint.API]
Vehicles() Vehicles
Optimize()
diff --git a/core/site_api.go b/core/site_api.go
index f25806ea3..19cee08ef 100644
--- a/core/site_api.go
+++ b/core/site_api.go
@@ -3,6 +3,7 @@ package core
import (
"errors"
"fmt"
+ "iter"
"slices"
"strings"
"time"
@@ -169,9 +170,26 @@ func (site *Site) GetBatteryMaxDischargePower() float64 {
return site.batteryMaxDischargePower
}
-// Loadpoints returns the loadpoints as api interfaces
+// Loadpoints returns the loadpoints as api interfaces.
+// Disabled loadpoints are returned as nil to keep indexes stable.
func (site *Site) Loadpoints() []loadpoint.API {
- return lo.Map(site.loadpoints, func(lp *Loadpoint, _ int) loadpoint.API { return lp })
+ return lo.Map(site.loadpoints, func(lp *Loadpoint, _ int) loadpoint.API {
+ if lp == nil {
+ return nil
+ }
+ return lp
+ })
+}
+
+// ActiveLoadpoints yields enabled loadpoints with their stable index
+func (site *Site) ActiveLoadpoints() iter.Seq2[int, loadpoint.API] {
+ return func(yield func(int, loadpoint.API) bool) {
+ for id, lp := range site.loadpoints {
+ if lp != nil && !yield(id, lp) {
+ return
+ }
+ }
+ }
}
func (site *Site) hasMeters() bool {
@@ -179,12 +197,17 @@ func (site *Site) hasMeters() bool {
}
func (site *Site) IsConfigured() bool {
- return len(site.loadpoints) > 0 || site.hasMeters()
+ return slices.ContainsFunc(site.loadpoints, func(lp *Loadpoint) bool { return lp != nil }) || site.hasMeters()
+}
+
+// activeLoadpoints returns the non-disabled loadpoints
+func (site *Site) activeLoadpoints() []*Loadpoint {
+ return lo.Filter(site.loadpoints, func(lp *Loadpoint, _ int) bool { return lp != nil })
}
// loadpointsAsCircuitDevices returns the loadpoints as circuit devices
func (site *Site) loadpointsAsCircuitDevices() []api.CircuitLoad {
- return lo.Map(site.loadpoints, func(lp *Loadpoint, _ int) api.CircuitLoad { return lp })
+ return lo.Map(site.activeLoadpoints(), func(lp *Loadpoint, _ int) api.CircuitLoad { return lp })
}
// Vehicles returns the site vehicles
diff --git a/core/site_api_test.go b/core/site_api_test.go
new file mode 100644
index 000000000..63671a266
--- /dev/null
+++ b/core/site_api_test.go
@@ -0,0 +1,22 @@
+package core
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestLoadpointsNilSlots(t *testing.T) {
+ site := &Site{loadpoints: []*Loadpoint{new(Loadpoint), nil, new(Loadpoint)}}
+
+ lps := site.Loadpoints()
+ assert.Len(t, lps, 3, "disabled loadpoints must keep their slot")
+
+ // disabled slot must be untyped nil, not a typed-nil interface
+ assert.True(t, lps[1] == nil)
+ assert.NotNil(t, lps[0])
+ assert.NotNil(t, lps[2])
+
+ assert.Len(t, site.activeLoadpoints(), 2)
+ assert.True(t, site.IsConfigured())
+}
diff --git a/core/site_battery.go b/core/site_battery.go
index 4cc18822a..d0eb2a691 100644
--- a/core/site_battery.go
+++ b/core/site_battery.go
@@ -208,7 +208,7 @@ func (site *Site) dischargeControlActive(rate api.Rate) bool {
return false
}
- for _, lp := range site.Loadpoints() {
+ for _, lp := range site.activeLoadpoints() {
smartCostActive := site.smartCostActive(lp, rate)
if lp.GetStatus() == api.StatusC && (smartCostActive || lp.IsFastChargingActive()) {
return true
diff --git a/core/site_optimizer.go b/core/site_optimizer.go
index 61da1a354..22a56d958 100644
--- a/core/site_optimizer.go
+++ b/core/site_optimizer.go
@@ -248,6 +248,10 @@ func (site *Site) suggestion(key, currentAction string) *types.Suggestion {
// publishSuggestions publishes the loadpoints' suggestions
func (site *Site) publishSuggestions() {
for id, lp := range site.loadpoints {
+ if lp == nil {
+ continue
+ }
+
var val any
if s := site.suggestion(loadpointKey(id), loadpointCurrentAction(lp)); s != nil {
val = *s
@@ -512,7 +516,7 @@ func (site *Site) optimizerRequest(battery []types.Measurement) (optimizer.Optim
// uncontrollable power of loadpoints that cannot be modelled as storage
var unmodelled float64
- for id, lp := range site.Loadpoints() {
+ for id, lp := range site.ActiveLoadpoints() {
// ignore disconnected loadpoints, including StatusNone
if s := lp.GetStatus(); s != api.StatusB && s != api.StatusC {
continue
diff --git a/hems/shm/shm.go b/hems/shm/shm.go
index c28d588a1..d731b2ff1 100644
--- a/hems/shm/shm.go
+++ b/hems/shm/shm.go
@@ -214,7 +214,7 @@ func (s *SEMP) deviceInfoQuery(w http.ResponseWriter, r *http.Request) {
if did == "" {
msg.DeviceInfo = append(msg.DeviceInfo, s.allDeviceInfo()...)
} else {
- for id, lp := range s.site.Loadpoints() {
+ for id, lp := range s.site.ActiveLoadpoints() {
if did != s.deviceID(id) {
continue
}
@@ -239,7 +239,7 @@ func (s *SEMP) deviceStatusQuery(w http.ResponseWriter, r *http.Request) {
if did == "" {
msg.DeviceStatus = append(msg.DeviceStatus, s.allDeviceStatus()...)
} else {
- for id, lp := range s.site.Loadpoints() {
+ for id, lp := range s.site.ActiveLoadpoints() {
if did != s.deviceID(id) {
continue
}
@@ -264,7 +264,7 @@ func (s *SEMP) devicePlanningQuery(w http.ResponseWriter, r *http.Request) {
if did == "" {
msg.PlanningRequest = append(msg.PlanningRequest, s.allPlanningRequest()...)
} else {
- for id, lp := range s.site.Loadpoints() {
+ for id, lp := range s.site.ActiveLoadpoints() {
if did != s.deviceID(id) {
continue
}
@@ -339,7 +339,7 @@ func (s *SEMP) deviceInfo(id int, lp loadpoint.API) DeviceInfo {
}
func (s *SEMP) allDeviceInfo() (res []DeviceInfo) {
- for id, lp := range s.site.Loadpoints() {
+ for id, lp := range s.site.ActiveLoadpoints() {
res = append(res, s.deviceInfo(id, lp))
}
@@ -370,7 +370,7 @@ func (s *SEMP) deviceStatus(id int, lp loadpoint.API) DeviceStatus {
}
func (s *SEMP) allDeviceStatus() (res []DeviceStatus) {
- for id, lp := range s.site.Loadpoints() {
+ for id, lp := range s.site.ActiveLoadpoints() {
res = append(res, s.deviceStatus(id, lp))
}
@@ -427,7 +427,7 @@ func (s *SEMP) planningRequest(id int, lp loadpoint.API) (res PlanningRequest) {
}
func (s *SEMP) allPlanningRequest() (res []PlanningRequest) {
- for id, lp := range s.site.Loadpoints() {
+ for id, lp := range s.site.ActiveLoadpoints() {
if pr := s.planningRequest(id, lp); len(pr.Timeframe) > 0 {
res = append(res, pr)
}
diff --git a/i18n/de.json b/i18n/de.json
index 5e2974e4a..173a8affc 100644
--- a/i18n/de.json
+++ b/i18n/de.json
@@ -320,17 +320,22 @@
"change": "Ändern",
"clear": "Löschen",
"close": "Schließen",
+ "confirmDisable": "Dieses Gerät deaktivieren? Es wird nach dem nächsten Neustart ignoriert.",
+ "confirmEnable": "Dieses Gerät wieder aktivieren?",
"confirmSave": "Es gibt ungespeicherte Änderungen. Jetzt speichern?",
"copied": "Kopiert!",
"copy": "Kopieren",
"customHelp": "Erstelle ein benutzerdefiniertes Gerät mit evcc's Plugin-System.",
"customOption": "Benutzerdefiniertes Gerät",
"delete": "Löschen",
+ "disable": "Deaktivieren",
+ "disabled": "deaktiviert",
"dismiss": "Ausblenden",
"docsLink": "Siehe Dokumentation.",
"dragHandle": "Verschieben",
"dragItem": "Verschiebbar: {title}",
"dragList": "Sortierbare Liste",
+ "enable": "Aktivieren",
"error": "Fehler",
"experimental": "Experimentell",
"forceSave": "Trotzdem speichern",
@@ -1724,6 +1729,7 @@
"label": "Sprache"
},
"loadpoints": {
+ "disabledDevice": "deaktiviert",
"help": "Reihenfolge und Sichtbarkeit für die Benutzeroberfläche ändern.",
"hide": "{title} ausblenden",
"label": "Ladepunkte",
diff --git a/i18n/en.json b/i18n/en.json
index ddd122c5b..5d9b8fdc6 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -320,17 +320,22 @@
"change": "Change",
"clear": "Clear",
"close": "Close",
+ "confirmDisable": "Disable this device? It will be ignored after the next restart.",
+ "confirmEnable": "Enable this device again?",
"confirmSave": "There are unsaved changes. Save now?",
"copied": "Copied!",
"copy": "Copy",
"customHelp": "Create a user-defined device using evcc's plugin system.",
"customOption": "User-defined device",
"delete": "Delete",
+ "disable": "Disable",
+ "disabled": "disabled",
"dismiss": "Dismiss",
"docsLink": "See documentation.",
"dragHandle": "Drag handle",
"dragItem": "Draggable: {title}",
"dragList": "Reorderable list",
+ "enable": "Enable",
"error": "Error",
"experimental": "Experimental",
"forceSave": "Save anyway",
@@ -1724,6 +1729,7 @@
"label": "Language"
},
"loadpoints": {
+ "disabledDevice": "disabled",
"help": "Change order and visibility for the UI.",
"hide": "Hide {title}",
"label": "Charging points",
diff --git a/server/http.go b/server/http.go
index cb08a71dc..c4725820b 100644
--- a/server/http.go
+++ b/server/http.go
@@ -226,7 +226,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API) {
// loadpoint api
// TODO any loadpoint
- for id, lp := range site.Loadpoints() {
+ for id, lp := range site.ActiveLoadpoints() {
api := api.PathPrefix(fmt.Sprintf("/loadpoints/%d", id+1)).Subrouter()
routes := map[string]route{
diff --git a/server/http_config_device_handler.go b/server/http_config_device_handler.go
index 5fb06ff64..d69ff7217 100644
--- a/server/http_config_device_handler.go
+++ b/server/http_config_device_handler.go
@@ -598,7 +598,7 @@ func deleteDeviceHandler(site site.API) func(w http.ResponseWriter, r *http.Requ
// cleanup references
for _, dev := range h.Devices() {
lp := dev.Instance()
- if lp.GetChargerRef() == config.NameForID(id) {
+ if lp != nil && lp.GetChargerRef() == config.NameForID(id) {
lp.SetChargerRef("")
}
}
@@ -628,7 +628,7 @@ func deleteDeviceHandler(site site.API) func(w http.ResponseWriter, r *http.Requ
for _, dev := range h.Devices() {
lp := dev.Instance()
- if lp.GetMeterRef() == name {
+ if lp != nil && lp.GetMeterRef() == name {
lp.SetMeterRef("")
}
}
@@ -639,7 +639,7 @@ func deleteDeviceHandler(site site.API) func(w http.ResponseWriter, r *http.Requ
// cleanup references
for _, dev := range h.Devices() {
lp := dev.Instance()
- if lp.GetDefaultVehicleRef() == config.NameForID(id) {
+ if lp != nil && lp.GetDefaultVehicleRef() == config.NameForID(id) {
lp.SetDefaultVehicleRef("")
}
}
@@ -650,7 +650,7 @@ func deleteDeviceHandler(site site.API) func(w http.ResponseWriter, r *http.Requ
// cleanup references
for _, dev := range h.Devices() {
lp := dev.Instance()
- if lp.GetCircuitRef() == config.NameForID(id) {
+ if lp != nil && lp.GetCircuitRef() == config.NameForID(id) {
lp.SetCircuitRef("")
}
}
diff --git a/server/http_config_helper.go b/server/http_config_helper.go
index 3604e858a..9acc17a1d 100644
--- a/server/http_config_helper.go
+++ b/server/http_config_helper.go
@@ -69,9 +69,15 @@ func propsToMap(props config.Properties) (map[string]any, error) {
}
return lo.PickBy(res, func(k string, v any) bool {
- if k == "Type" || v.(string) == "" {
+ if k == "Type" {
return false
}
+ switch val := v.(type) {
+ case string:
+ return val != ""
+ case bool:
+ return val
+ }
return true
}), nil
}
diff --git a/server/http_config_helper_test.go b/server/http_config_helper_test.go
index e0af6b674..eb89ca36b 100644
--- a/server/http_config_helper_test.go
+++ b/server/http_config_helper_test.go
@@ -106,19 +106,34 @@ func TestConfigReqUnmarshal(t *testing.T) {
}
func TestConfigReqMarshalToMap(t *testing.T) {
- props := config.Properties{
+ res, err := propsToMap(config.Properties{
Type: "type",
Title: "title",
Product: "product",
- }
-
- res, err := propsToMap(props)
+ })
require.NoError(t, err)
-
assert.Equal(t, map[string]any{
"deviceTitle": "title",
"deviceProduct": "product",
}, res)
+
+ // Disable=false is omitted (zero value)
+ res, err = propsToMap(config.Properties{
+ Type: "type",
+ Title: "title",
+ })
+ require.NoError(t, err)
+ assert.NotContains(t, res, "deviceDisable")
+
+ // Disable=true is included
+ res, err = propsToMap(config.Properties{
+ Type: "type",
+ Disable: true,
+ })
+ require.NoError(t, err)
+ assert.Equal(t, map[string]any{
+ "deviceDisable": true,
+ }, res)
}
type testStruct struct {
diff --git a/server/http_config_loadpoint_handler.go b/server/http_config_loadpoint_handler.go
index 97f820336..23f875c48 100644
--- a/server/http_config_loadpoint_handler.go
+++ b/server/http_config_loadpoint_handler.go
@@ -14,7 +14,6 @@ import (
"github.com/evcc-io/evcc/util/config"
"github.com/evcc-io/evcc/util/templates"
"github.com/gorilla/mux"
- "github.com/samber/lo"
)
func getLoadpointStaticConfig(lp loadpoint.API) loadpoint.StaticConfig {
@@ -50,68 +49,116 @@ func getLoadpointDynamicConfig(lp loadpoint.API) loadpoint.DynamicConfig {
}
type loadpointFullConfig struct {
- ID int `json:"id,omitempty"` // db row id
- Name string `json:"name"` // either slice index (yaml) or db:
+ ID int `json:"id,omitempty"` // db row id
+ Name string `json:"name"` // either slice index (yaml) or db:
+ Disable bool `json:"disable,omitempty"`
// static config
loadpoint.StaticConfig
loadpoint.DynamicConfig
}
-func loadpointSplitConfig(r io.Reader) (loadpoint.DynamicConfig, map[string]any, error) {
+func loadpointSplitConfig(r io.Reader) (loadpoint.DynamicConfig, map[string]any, map[string]any, *bool, error) {
var payload map[string]any
if err := jsonDecoder(r).Decode(&payload); err != nil {
- return loadpoint.DynamicConfig{}, nil, err
+ return loadpoint.DynamicConfig{}, nil, nil, nil, err
}
- return loadpoint.SplitConfig(payload)
+ // nil if not part of the payload- keeps the persisted flag
+ var disable *bool
+ if v, ok := payload["disable"].(bool); ok {
+ disable = &v
+ }
+ delete(payload, "disable")
+ delete(payload, "id")
+ delete(payload, "name")
+
+ dynamic, static, err := loadpoint.SplitConfig(payload)
+ return dynamic, static, payload, disable, err
+}
+
+// setDeviceDisable persists the disable flag on a loadpoint's subdevice
+func setDeviceDisable[T any](name string, h config.Handler[T], disable bool) error {
+ if name == "" {
+ return nil
+ }
+
+ dev, err := configurableDevice(name, h)
+ if err != nil {
+ // static device- cannot persist
+ return nil
+ }
+
+ props := dev.Properties()
+ if props.Disable == disable {
+ return nil
+ }
+ props.Disable = disable
+
+ return dev.Update(dev.Config().Other, dev.Instance(), config.WithProperties(props))
}
// loadpointConfig returns a single loadpoint's configuration
-func loadpointConfig(dev config.Device[loadpoint.API]) loadpointFullConfig {
- var id int
+func loadpointConfig(dev config.Device[loadpoint.API]) (loadpointFullConfig, error) {
+ var (
+ id int
+ disable bool
+ )
if configurable, ok := dev.(config.ConfigurableDevice[loadpoint.API]); ok {
id = configurable.ID()
+ disable = configurable.Properties().Disable
}
lp := dev.Instance()
- // // missing instance due to error, decode config from database
- // if lp == nil || reflect.ValueOf(lp).IsNil() {
- // cc := dev.Config()
+ // disabled loadpoint has no live instance; decode static config from the database instead
+ if lp == nil {
+ dynamic, staticMap, err := loadpoint.SplitConfig(dev.Config().Other)
+ if err != nil {
+ return loadpointFullConfig{}, err
+ }
- // dynamic, staticMap, _ := loadpoint.SplitConfig(cc.Other)
+ var static loadpoint.StaticConfig
+ if err := util.DecodeOther(staticMap, &static); err != nil {
+ return loadpointFullConfig{}, err
+ }
- // var static loadpoint.StaticConfig
- // _ = util.DecodeOther(staticMap, &static)
-
- // res := loadpointFullConfig{
- // ID: id,
- // Name: dev.Config().Name,
- // StaticConfig: static,
- // DynamicConfig: dynamic,
- // }
-
- // return res
- // }
+ return loadpointFullConfig{
+ ID: id,
+ Name: dev.Config().Name,
+ Disable: disable,
+ StaticConfig: static,
+ DynamicConfig: dynamic,
+ }, nil
+ }
res := loadpointFullConfig{
ID: id,
Name: dev.Config().Name,
+ Disable: disable,
StaticConfig: getLoadpointStaticConfig(lp),
DynamicConfig: getLoadpointDynamicConfig(lp),
}
- return res
+ return res, nil
}
// loadpointsConfigHandler returns a device configurations by class
func loadpointsConfigHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- res := lo.Map(config.Loadpoints().Devices(), func(dev config.Device[loadpoint.API], _ int) loadpointFullConfig {
- return loadpointConfig(dev)
- })
+ devices := config.Loadpoints().Devices()
+
+ res := make([]loadpointFullConfig, 0, len(devices))
+ for _, dev := range devices {
+ c, err := loadpointConfig(dev)
+ if err != nil {
+ jsonError(w, http.StatusBadRequest, err)
+ return
+ }
+
+ res = append(res, c)
+ }
jsonWrite(w, res)
}
@@ -136,7 +183,11 @@ func loadpointConfigHandler() http.HandlerFunc {
return
}
- res := loadpointConfig(dev)
+ res, err := loadpointConfig(dev)
+ if err != nil {
+ jsonError(w, http.StatusBadRequest, err)
+ return
+ }
jsonWrite(w, res)
}
@@ -149,7 +200,7 @@ func newLoadpointHandler() http.HandlerFunc {
// TODO revert charger, meter etc
return func(w http.ResponseWriter, r *http.Request) {
- dynamic, static, err := loadpointSplitConfig(r.Body)
+ dynamic, static, _, disable, err := loadpointSplitConfig(r.Body)
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
@@ -159,7 +210,7 @@ func newLoadpointHandler() http.HandlerFunc {
name := "lp-" + strconv.Itoa(id+1)
log := util.NewLoggerWithLoadpoint(name, id+1)
- conf, err := config.AddConfig(templates.Loadpoint, static)
+ conf, err := config.AddConfig(templates.Loadpoint, static, config.WithProperties(config.Properties{Disable: disable != nil && *disable}))
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
@@ -220,34 +271,58 @@ func updateLoadpointHandler() http.HandlerFunc {
return
}
- dynamic, static, err := loadpointSplitConfig(r.Body)
+ dynamic, static, payload, disable, err := loadpointSplitConfig(r.Body)
if err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
- // static
-
- // merge here to maintain dynamic part of the config
- other := configurable.Config().Other
- if err := mergo.Merge(&other, static, mergo.WithOverride); err != nil {
- jsonError(w, http.StatusBadRequest, err)
- return
+ props := configurable.Properties()
+ if disable != nil {
+ props.Disable = *disable
}
instance := dev.Instance()
- if err := configurable.Update(other, instance); err != nil {
+ // merge static config to maintain the dynamic part; without live instance
+ // merge the full payload since dynamic setters cannot persist it
+ src := static
+ if instance == nil {
+ src = payload
+ }
+
+ other := configurable.Config().Other
+ if err := mergo.Merge(&other, src, mergo.WithOverride); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
- // dynamic
- if err := dynamic.Apply(instance); err != nil {
+ if err := configurable.Update(other, instance, config.WithProperties(props)); err != nil {
jsonError(w, http.StatusBadRequest, err)
return
}
+ // propagate disable to the loadpoint's charger and meter
+ chargerRef, _ := other["charger"].(string)
+ if err := setDeviceDisable(chargerRef, config.Chargers(), props.Disable); err != nil {
+ jsonError(w, http.StatusBadRequest, err)
+ return
+ }
+
+ meterRef, _ := other["meter"].(string)
+ if err := setDeviceDisable(meterRef, config.Meters(), props.Disable); err != nil {
+ jsonError(w, http.StatusBadRequest, err)
+ return
+ }
+
+ // dynamic; instance is nil for a disabled loadpoint, takes effect on next restart
+ if instance != nil {
+ if err := dynamic.Apply(instance); err != nil {
+ jsonError(w, http.StatusBadRequest, err)
+ return
+ }
+ }
+
setConfigDirty()
w.WriteHeader(http.StatusOK)
@@ -276,6 +351,20 @@ func deleteLoadpointHandler() http.HandlerFunc {
instance := lp.Instance()
+ // disabled loadpoint has no live instance; delete it without ref cleanup
+ if instance == nil {
+ if err := deleteDevice(id, h); err != nil {
+ jsonError(w, http.StatusBadRequest, err)
+ return
+ }
+
+ jsonWrite(w, struct {
+ ID int `json:"id"`
+ }{ID: id})
+
+ return
+ }
+
if dev, err := configurableDevice(instance.GetChargerRef(), config.Chargers()); err == nil {
if err := deleteDevice(dev.ID(), config.Chargers()); err != nil {
jsonError(w, http.StatusBadRequest, err)
diff --git a/server/http_config_loadpoint_handler_test.go b/server/http_config_loadpoint_handler_test.go
new file mode 100644
index 000000000..e9ddf4a49
--- /dev/null
+++ b/server/http_config_loadpoint_handler_test.go
@@ -0,0 +1,70 @@
+package server
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "testing"
+
+ "github.com/evcc-io/evcc/core/loadpoint"
+ "github.com/evcc-io/evcc/server/db"
+ "github.com/evcc-io/evcc/util/config"
+ "github.com/evcc-io/evcc/util/templates"
+ "github.com/gorilla/mux"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestLoadpointConfigDisabledNilInstance ensures loadpointConfig does not panic
+// for a disabled loadpoint whose instance was never created (nil loadpoint.API).
+func TestLoadpointConfigDisabledNilInstance(t *testing.T) {
+ conf := config.Config{
+ Class: templates.Loadpoint,
+ Properties: config.Properties{Disable: true},
+ Data: map[string]any{
+ "charger": "wallbox",
+ "meter": "lp-meter",
+ "title": "Garage",
+ },
+ }
+
+ var instance loadpoint.API // nil: disabled loadpoint has no live instance
+ dev := config.NewConfigurableDevice(&conf, instance)
+
+ var res loadpointFullConfig
+ require.NotPanics(t, func() {
+ var err error
+ res, err = loadpointConfig(dev)
+ require.NoError(t, err)
+ })
+
+ assert.True(t, res.Disable)
+ assert.Equal(t, "wallbox", res.Charger)
+ assert.Equal(t, "lp-meter", res.Meter)
+}
+
+// TestDeleteLoadpointDisabledNilInstance ensures deleteLoadpointHandler does not
+// dereference a nil instance when deleting a disabled loadpoint.
+func TestDeleteLoadpointDisabledNilInstance(t *testing.T) {
+ require.NoError(t, db.NewInstance("sqlite", ":memory:"))
+
+ conf, err := config.AddConfig(templates.Loadpoint,
+ map[string]any{"charger": "wallbox", "title": "Garage"},
+ config.WithProperties(config.Properties{Disable: true}),
+ )
+ require.NoError(t, err)
+
+ var instance loadpoint.API // nil: disabled loadpoint has no live instance
+ dev := config.NewConfigurableDevice(&conf, instance)
+ require.NoError(t, config.Loadpoints().Add(dev))
+ t.Cleanup(func() { _ = config.Loadpoints().Delete(config.NameForID(conf.ID)) })
+
+ req := httptest.NewRequest(http.MethodDelete, "/", nil)
+ req = mux.SetURLVars(req, map[string]string{"id": strconv.Itoa(conf.ID)})
+ rec := httptest.NewRecorder()
+
+ require.NotPanics(t, func() {
+ deleteLoadpointHandler()(rec, req)
+ })
+ assert.Equal(t, http.StatusOK, rec.Code)
+}
diff --git a/server/http_site_handler.go b/server/http_site_handler.go
index c6446205a..96cbd1de5 100644
--- a/server/http_site_handler.go
+++ b/server/http_site_handler.go
@@ -215,7 +215,7 @@ func updateSmartCostLimit(site site.API, setLimit func(loadpoint.API, *float64))
val = &f
}
- for _, lp := range site.Loadpoints() {
+ for _, lp := range site.ActiveLoadpoints() {
setLimit(lp, val)
}
diff --git a/server/influxdb.go b/server/influxdb.go
index 2774b85ee..fee3b78ec 100644
--- a/server/influxdb.go
+++ b/server/influxdb.go
@@ -185,6 +185,10 @@ func (m *Influx) Run(site site.API, in <-chan util.Param) {
tags := make(map[string]string)
if param.Loadpoint != nil {
lp := site.Loadpoints()[*param.Loadpoint]
+ if lp == nil {
+ // disabled loadpoint- placeholder params only
+ continue
+ }
tags["loadpoint"] = lp.GetTitle()
if v := lp.GetVehicle(); v != nil {
diff --git a/server/mcp/openapi.json b/server/mcp/openapi.json
index 6c0f36efd..89cd27dfc 100644
--- a/server/mcp/openapi.json
+++ b/server/mcp/openapi.json
@@ -3133,6 +3133,10 @@
"description": "Duration since the vehicle was connected, in seconds.",
"type": "number"
},
+ "disabled": {
+ "description": "Loadpoint is disabled via configuration.",
+ "type": "boolean"
+ },
"disableDelay": {
"description": "Delay before charging stops in solar mode, in seconds.",
"type": "number"
diff --git a/server/mqtt.go b/server/mqtt.go
index cfc8400f6..cba58b07b 100644
--- a/server/mqtt.go
+++ b/server/mqtt.go
@@ -192,7 +192,7 @@ func (m *MQTT) Listen(site site.API) error {
}
// loadpoint setters
- for id, lp := range site.Loadpoints() {
+ for id, lp := range site.ActiveLoadpoints() {
topic := fmt.Sprintf("%s/loadpoints/%d", m.root, id+1)
if err := m.listenLoadpointSetters(topic, site, lp); err != nil {
return err
@@ -221,12 +221,12 @@ func (m *MQTT) listenSiteSetters(topic string, site site.API) error {
{"gridExportLimit", floatSetter(site.SetGridExportLimit)},
{"solarAdjusted", boolSetter(pass(site.SetSolarAdjusted))},
{"smartCostLimit", floatPtrSetter(pass(func(limit *float64) {
- for _, lp := range site.Loadpoints() {
+ for _, lp := range site.ActiveLoadpoints() {
lp.SetSmartCostLimit(limit)
}
}))},
{"smartFeedInPriorityLimit", floatPtrSetter(pass(func(limit *float64) {
- for _, lp := range site.Loadpoints() {
+ for _, lp := range site.ActiveLoadpoints() {
lp.SetSmartFeedInPriorityLimit(limit)
}
}))},
diff --git a/server/openapi.state.yaml b/server/openapi.state.yaml
index cace537f3..9fe20c25d 100644
--- a/server/openapi.state.yaml
+++ b/server/openapi.state.yaml
@@ -827,6 +827,9 @@ components:
connectedDuration:
description: Duration since the vehicle was connected, in seconds.
type: number
+ disabled:
+ description: Loadpoint is disabled via configuration.
+ type: boolean
disableDelay:
description: Delay before charging stops in solar mode, in seconds.
type: number
diff --git a/tests/config-disable.spec.ts b/tests/config-disable.spec.ts
new file mode 100644
index 000000000..4d94087ed
--- /dev/null
+++ b/tests/config-disable.spec.ts
@@ -0,0 +1,415 @@
+import { test, expect, type Page, type Locator } from "@playwright/test";
+import { start, stop, restart, baseUrl } from "./evcc";
+import { startSimulator, stopSimulator, simulatorHost } from "./simulator";
+import {
+ expectModalVisible,
+ expectModalHidden,
+ addDemoCharger,
+ newLoadpoint,
+ finishLoadpoint,
+ openMoreMenu,
+ editorClear,
+ editorPaste,
+} from "./utils";
+
+test.use({ baseURL: baseUrl() });
+test.describe.configure({ mode: "parallel" });
+
+test.afterEach(async () => {
+ await stop();
+});
+
+async function expectNoFatal(page: Page) {
+ await expect(page.getByTestId("fatal-error")).not.toBeVisible();
+}
+
+function autoAcceptDialogs(page: Page) {
+ page.on("dialog", (dialog) => dialog.accept());
+}
+
+function disabledBadge(card: Locator) {
+ return card.getByRole("button", { name: "Enable" });
+}
+
+async function createLoadpoint(page: Page, title: string) {
+ await newLoadpoint(page, title);
+ await addDemoCharger(page);
+ // charger save instant-creates the loadpoint
+ await finishLoadpoint(page);
+}
+
+async function toggleLoadpointDisable(page: Page, index: number, action: "Disable" | "Enable") {
+ const target = page.getByTestId("loadpoint").nth(index);
+ const lpModal = page.getByTestId("loadpoint-modal");
+ await target.getByRole("button", { name: "edit" }).click();
+ await expectModalVisible(lpModal);
+ await lpModal.getByRole("button", { name: action }).click();
+ await expectModalHidden(lpModal);
+}
+
+test.describe("disable / enable", async () => {
+ test("loadpoint", async ({ page }) => {
+ autoAcceptDialogs(page);
+ await start();
+ await page.goto("/#/config");
+
+ // two loadpoints so disabling one keeps the site valid
+ await createLoadpoint(page, "Carport");
+ // reload between creations to reset modal state
+ await page.reload();
+ await createLoadpoint(page, "Garage");
+
+ const target = page.getByTestId("loadpoint").nth(0);
+
+ await toggleLoadpointDisable(page, 0, "Disable");
+
+ // card shows disabled state
+ await expect(disabledBadge(target)).toBeVisible();
+
+ // restart, no fatal
+ await restart();
+ await page.reload();
+ await expectNoFatal(page);
+ await expect(disabledBadge(target)).toBeVisible();
+
+ // re-enable by clicking the disabled card
+ await disabledBadge(target).click();
+ await expect(disabledBadge(target)).toHaveCount(0);
+
+ // restart, no fatal
+ await restart();
+ await page.reload();
+ await expectNoFatal(page);
+ await expect(disabledBadge(target)).toHaveCount(0);
+ });
+
+ test("grid meter", async ({ page }) => {
+ autoAcceptDialogs(page);
+ await start();
+ await page.goto("/#/config");
+
+ const meterModal = page.getByTestId("meter-modal");
+
+ // add grid meter
+ await page.getByRole("button", { name: "Add grid meter" }).click();
+ await expectModalVisible(meterModal);
+ await meterModal.getByLabel("Manufacturer").selectOption("Demo meter");
+ await meterModal.getByLabel("Power").fill("0");
+ await meterModal.getByRole("button", { name: "Save" }).click();
+ await expectModalHidden(meterModal);
+
+ const gridCard = page.getByTestId("grid");
+
+ // open modal, disable
+ await gridCard.getByRole("button", { name: "edit" }).click();
+ await expectModalVisible(meterModal);
+ await expect(meterModal.getByRole("button", { name: "Disable" })).toBeVisible();
+ await meterModal.getByRole("button", { name: "Disable" }).click();
+ await expectModalHidden(meterModal);
+
+ // card shows disabled state
+ await expect(disabledBadge(gridCard)).toBeVisible();
+
+ // restart, no fatal
+ await restart();
+ await page.reload();
+ await expectNoFatal(page);
+ await expect(disabledBadge(gridCard)).toBeVisible();
+
+ // re-enable by clicking the disabled card
+ await disabledBadge(gridCard).click();
+ await expect(disabledBadge(gridCard)).toHaveCount(0);
+
+ // restart, no fatal
+ await restart();
+ await page.reload();
+ await expectNoFatal(page);
+ await expect(disabledBadge(gridCard)).toHaveCount(0);
+ });
+
+ test("pv meter", async ({ page }) => {
+ autoAcceptDialogs(page);
+ await start();
+ await page.goto("/#/config");
+
+ const meterModal = page.getByTestId("meter-modal");
+
+ // add pv meter
+ await page.getByRole("button", { name: "Add solar or battery" }).click();
+ await expectModalVisible(meterModal);
+ await meterModal.getByRole("button", { name: "Add solar meter" }).click();
+ await meterModal.getByLabel("Manufacturer").selectOption("Demo meter");
+ await meterModal.getByLabel("Title").fill("PV");
+ await meterModal.getByLabel("Power").fill("0");
+ await meterModal.getByRole("button", { name: "Save" }).click();
+ await expectModalHidden(meterModal);
+
+ const pvCard = page.getByTestId("pv");
+
+ // open modal, disable
+ await pvCard.getByRole("button", { name: "edit" }).click();
+ await expectModalVisible(meterModal);
+ await expect(meterModal.getByRole("button", { name: "Disable" })).toBeVisible();
+ await meterModal.getByRole("button", { name: "Disable" }).click();
+ await expectModalHidden(meterModal);
+
+ // card shows disabled state
+ await expect(disabledBadge(pvCard)).toBeVisible();
+
+ // restart, no fatal
+ await restart();
+ await page.reload();
+ await expectNoFatal(page);
+ await expect(disabledBadge(pvCard)).toBeVisible();
+
+ // re-enable by clicking the disabled card
+ await disabledBadge(pvCard).click();
+ await expect(disabledBadge(pvCard)).toHaveCount(0);
+
+ // restart, no fatal
+ await restart();
+ await page.reload();
+ await expectNoFatal(page);
+ await expect(disabledBadge(pvCard)).toHaveCount(0);
+ });
+
+ test("grid tariff", async ({ page }) => {
+ autoAcceptDialogs(page);
+ await start();
+ await page.goto("/#/config");
+
+ const tariffModal = page.getByTestId("tariff-modal");
+ const gridCard = page.getByTestId("tariff-grid");
+
+ // add grid tariff
+ await page.getByRole("button", { name: "Add tariff" }).click();
+ await expectModalVisible(tariffModal);
+ await tariffModal.getByRole("button", { name: "Add grid import tariff" }).click();
+ await tariffModal.getByLabel("Provider").selectOption("Fixed Price");
+ await tariffModal.getByLabel("Price").fill("32.1");
+ await tariffModal.getByRole("button", { name: "Validate & save" }).click();
+ await expectModalHidden(tariffModal);
+
+ // open modal, disable
+ await gridCard.getByRole("button", { name: "edit" }).click();
+ await expectModalVisible(tariffModal);
+ await tariffModal.getByRole("button", { name: "Disable" }).click();
+ await expectModalHidden(tariffModal);
+
+ // card shows disabled state
+ await expect(disabledBadge(gridCard)).toBeVisible();
+
+ // restart, tariff not instantiated
+ await restart();
+ await page.reload();
+ await expectNoFatal(page);
+ await expect(disabledBadge(gridCard)).toBeVisible();
+ await expect(gridCard).not.toContainText("32.1");
+
+ // re-enable by clicking the disabled card
+ await disabledBadge(gridCard).click();
+ await expect(disabledBadge(gridCard)).toHaveCount(0);
+
+ // restart, tariff active again
+ await restart();
+ await page.reload();
+ await expectNoFatal(page);
+ await expect(disabledBadge(gridCard)).toHaveCount(0);
+ await expect(gridCard).toContainText(["Price", "32.1 ct"].join(""));
+ });
+
+ test("user-defined vehicle", async ({ page }) => {
+ autoAcceptDialogs(page);
+ await start();
+ await page.goto("/#/config");
+
+ // add user-defined vehicle
+ await page.getByTestId("add-vehicle").click();
+ const modal = page.getByTestId("vehicle-modal");
+ await expectModalVisible(modal);
+ await modal.getByLabel("Manufacturer").selectOption("User-defined device");
+ await page.waitForLoadState("networkidle");
+ const editor = modal.getByTestId("yaml-editor");
+ await editorClear(editor);
+ await editorPaste(
+ editor,
+ page,
+ `title: blue Honda
+capacity: 12.3
+soc:
+ source: const
+ value: 42`
+ );
+ await modal.getByRole("button", { name: "Save" }).click();
+ await expectModalHidden(modal);
+
+ const vehicleCard = page.getByTestId("vehicle");
+ await expect(vehicleCard).toHaveCount(1);
+
+ // open modal, disable
+ await vehicleCard.getByRole("button", { name: "edit" }).click();
+ await expectModalVisible(modal);
+ await modal.getByRole("button", { name: "Disable" }).click();
+ await expectModalHidden(modal);
+
+ // card shows disabled state
+ await expect(disabledBadge(vehicleCard)).toBeVisible();
+
+ // restart, no fatal
+ await restart();
+ await page.reload();
+ await expectNoFatal(page);
+ await expect(disabledBadge(vehicleCard)).toBeVisible();
+
+ // re-enable by clicking the disabled card
+ await disabledBadge(vehicleCard).click();
+ await expect(disabledBadge(vehicleCard)).toHaveCount(0);
+
+ // restart, yaml config intact
+ await restart();
+ await page.reload();
+ await expectNoFatal(page);
+ await expect(vehicleCard).toContainText("blue Honda");
+ });
+});
+
+test.describe("disabled loadpoint behavior", async () => {
+ test("broken charger boots after disabling its loadpoint", async ({ page }) => {
+ autoAcceptDialogs(page);
+ await startSimulator();
+ await start();
+ await page.goto("/#/config");
+
+ // second loadpoint so the site stays valid
+ await createLoadpoint(page, "Carport");
+ await page.reload();
+
+ // loadpoint with shelly charger against simulator
+ await newLoadpoint(page, "Garage");
+ const lpModal = page.getByTestId("loadpoint-modal");
+ await lpModal.getByRole("button", { name: "Add charger" }).click();
+ const chargerModal = page.getByTestId("charger-modal");
+ await expectModalVisible(chargerModal);
+ await chargerModal.getByLabel("Manufacturer").selectOption("Shelly 1");
+ await chargerModal.getByLabel("IP address or hostname").fill(simulatorHost());
+ await chargerModal.getByRole("button", { name: "Validate & save" }).click();
+ await expectModalHidden(chargerModal);
+ await expectModalVisible(lpModal);
+ // charger save instant-creates the loadpoint
+ await finishLoadpoint(page);
+ await page.waitForLoadState("networkidle");
+
+ // break charger
+ await stopSimulator();
+ await restart();
+ await page.reload();
+ await expect(page.getByTestId("fatal-error")).toBeVisible();
+
+ // disable loadpoint with broken charger
+ await toggleLoadpointDisable(page, 1, "Disable");
+ await expect(disabledBadge(page.getByTestId("loadpoint").nth(1))).toBeVisible();
+
+ // clean boot, charger no longer instantiated
+ await restart();
+ await page.reload();
+ await expectNoFatal(page);
+ await expect(disabledBadge(page.getByTestId("loadpoint").nth(1))).toBeVisible();
+
+ // re-enable, fatal returns
+ await disabledBadge(page.getByTestId("loadpoint").nth(1)).click();
+ await expect(disabledBadge(page.getByTestId("loadpoint").nth(1))).toHaveCount(0);
+ await restart();
+ await page.reload();
+ await expect(page.getByTestId("fatal-error")).toBeVisible();
+ });
+
+ test("disabling a loadpoint keeps api indexes stable", async ({ page }) => {
+ autoAcceptDialogs(page);
+ await start();
+ await page.goto("/#/config");
+
+ await createLoadpoint(page, "Carport");
+ await page.reload();
+ await createLoadpoint(page, "Garage");
+ await page.reload();
+ await createLoadpoint(page, "Süd");
+
+ // disable middle loadpoint
+ await toggleLoadpointDisable(page, 1, "Disable");
+ await restart();
+
+ // main ui hides disabled loadpoint
+ await page.goto("/");
+ await expect(page.getByTestId("loadpoint")).toHaveCount(2);
+ await expect(page.getByRole("heading", { name: "Garage" })).not.toBeVisible();
+ await expect(page.getByTestId("loadpoint").nth(0)).toContainText("Carport");
+ await expect(page.getByTestId("loadpoint").nth(1)).toContainText("Süd");
+
+ // state keeps disabled loadpoint at its position
+ const state = await (await page.request.get("/api/state")).json();
+ expect(state.loadpoints).toHaveLength(3);
+ expect(state.loadpoints[1].disabled).toBe(true);
+ expect(state.loadpoints[1].title).toBe("Garage");
+
+ // api index of third loadpoint does not shift
+ const res = await page.request.post("/api/loadpoints/3/mode/now");
+ expect(res.status()).toBe(200);
+ const updated = await (await page.request.get("/api/state")).json();
+ expect(updated.loadpoints[2].mode).toBe("now");
+ expect(updated.loadpoints[0].mode).not.toBe("now");
+
+ // disabled loadpoint has no api routes
+ const disabledRes = await page.request.post("/api/loadpoints/2/mode/now");
+ expect(disabledRes.status()).toBe(404);
+
+ // ui settings show disabled loadpoint as inactive row
+ const menu = await openMoreMenu(page);
+ await menu.getByRole("button", { name: "User Interface" }).click();
+ const modal = page.getByTestId("global-settings-modal");
+ await expectModalVisible(modal);
+ const garageRow = modal.getByRole("listitem", { name: "Draggable: Garage" });
+ await expect(garageRow).toContainText("disabled");
+ await expect(modal.getByRole("switch", { name: "Hide Garage" })).toHaveCount(0);
+ await expect(modal.getByRole("switch", { name: "Hide Carport" })).toBeEnabled();
+
+ // last-visible guard ignores disabled loadpoint
+ await modal.getByRole("switch", { name: "Hide Carport" }).click();
+ await expect(modal.getByRole("switch", { name: "Hide Süd" })).toBeDisabled();
+ });
+
+ test("editing a disabled loadpoint", async ({ page }) => {
+ autoAcceptDialogs(page);
+ await start();
+ await page.goto("/#/config");
+
+ await createLoadpoint(page, "Carport");
+ await page.reload();
+ await createLoadpoint(page, "Garage");
+
+ await toggleLoadpointDisable(page, 1, "Disable");
+
+ const target = page.getByTestId("loadpoint").nth(1);
+ const lpModal = page.getByTestId("loadpoint-modal");
+
+ // update title while disabled
+ await target.getByRole("button", { name: "edit" }).click();
+ await expectModalVisible(lpModal);
+ await lpModal.getByLabel("Title").fill("Garage 2");
+ await lpModal.getByRole("button", { name: "Save" }).click();
+ await expectModalHidden(lpModal);
+ await expect(target).toContainText("Garage 2");
+
+ // survives restart, still disabled
+ await restart();
+ await page.reload();
+ await expect(target).toContainText("Garage 2");
+ await expect(disabledBadge(target)).toBeVisible();
+
+ // re-enable, title applies to live loadpoint
+ await disabledBadge(target).click();
+ await expect(disabledBadge(target)).toHaveCount(0);
+ await restart();
+ await page.goto("/");
+ await expect(page.getByRole("heading", { name: "Garage 2" })).toBeVisible();
+ });
+});
diff --git a/util/config/config.go b/util/config/config.go
index 879c73eec..0e8d14f18 100644
--- a/util/config/config.go
+++ b/util/config/config.go
@@ -24,6 +24,7 @@ type Config struct {
type Properties struct {
Type string
+ Disable bool `json:"deviceDisable,omitempty" mapstructure:"deviceDisable"`
Title string `json:"deviceTitle,omitempty" mapstructure:"deviceTitle"`
Icon string `json:"deviceIcon,omitempty" mapstructure:"deviceIcon"`
Product string `json:"deviceProduct,omitempty" mapstructure:"deviceProduct"`
diff --git a/util/config/instance.go b/util/config/instance.go
index 430a3b2d4..f26e7bfa8 100644
--- a/util/config/instance.go
+++ b/util/config/instance.go
@@ -79,7 +79,10 @@ func Tariffs() Handler[api.Tariff] {
func Instances[T any](devices []Device[T]) []T {
res := make([]T, 0, len(devices))
for _, dev := range devices {
- res = append(res, dev.Instance())
+ // skip disabled devices without instance
+ if inst := dev.Instance(); any(inst) != nil {
+ res = append(res, inst)
+ }
}
return res
}
diff --git a/vite.config.ts b/vite.config.ts
index 953be0b01..397fd5a70 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -49,6 +49,7 @@ export default defineConfig({
files: ["assets/**/*.{ts,js,vue}", "tests/**/*.ts"],
rules: {
"no-param-reassign": "error",
+ "no-unused-vars": ["error", { ignoreRestSiblings: true }],
"vue/require-default-prop": "off",
"vue/no-reserved-component-names": "off",
"typescript/no-explicit-any": "off",