From db11f1d7c5ca828a0534634743b203e118450eba Mon Sep 17 00:00:00 2001 From: Maschga <88616799+Maschga@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:08:53 +0200 Subject: [PATCH] Config UI: make circuits configurable (#27842) --- charger/charger.go | 2 +- cmd/setup.go | 75 +++++++++++-- core/circuit/circuit.go | 12 +- core/circuit/config.go | 28 +++++ core/circuit/template.go | 21 ++++ messenger/messenger.go | 2 +- meter/meter.go | 2 +- server/http_config_device_handler.go | 12 +- templates/definition/circuit/static.yaml | 33 ++++++ templates/definition/embed.go | 2 +- tests/config-circuit-device.spec.ts | 133 +++++++++++++++++++++++ util/templates/defaults.yaml | 1 + util/templates/init.go | 2 +- vehicle/vehicle.go | 2 +- 14 files changed, 302 insertions(+), 25 deletions(-) create mode 100644 core/circuit/template.go create mode 100644 templates/definition/circuit/static.yaml create mode 100644 tests/config-circuit-device.spec.ts diff --git a/charger/charger.go b/charger/charger.go index 73ec87914..8452ece0d 100644 --- a/charger/charger.go +++ b/charger/charger.go @@ -27,7 +27,7 @@ func init() { //go:generate go tool decorate -f decorateCustom -b *Charger -r api.Charger -t api.ChargerEx,api.Identifier,api.PhaseSwitcher,api.Resurrector,api.Battery,api.SocLimiter,api.Meter,api.MeterEnergy,api.PhaseCurrents,api.PhaseVoltages -// NewConfigurableFromConfig creates a new configurable charger +// NewConfigurableFromConfig creates a new charger from config func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.Charger, error) { var cc struct { embed `mapstructure:",squash"` diff --git a/cmd/setup.go b/cmd/setup.go index e1574da8b..a923e3d39 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -87,6 +87,7 @@ var yamlSource struct { eebus globalconfig.YamlSource tariffs globalconfig.YamlSource messaging globalconfig.YamlSource + circuits globalconfig.YamlSource } var nameRE = regexp.MustCompile(`^[a-zA-Z0-9_.:-]+$`) @@ -157,19 +158,56 @@ func isWritable(filePath string) bool { } func configureCircuits(conf *[]config.Named) error { - // migrate settings + // yaml config from file + if len(*conf) != 0 { + yamlSource.circuits = globalconfig.YamlSourceFile + } + + // yaml config from db (deprecated) if settings.Exists(keys.Circuits) { + if yamlSource.circuits == globalconfig.YamlSourceFile { + // just warn, no error to not break previous behavior + log.WARN.Println("circuits configured via UI yaml; evcc.yaml config will be ignored") + } *conf = []config.Named{} if err := settings.Yaml(keys.Circuits, new([]map[string]any), &conf); err != nil { return err } + yamlSource.circuits = globalconfig.YamlSourceDb } - children := slices.Clone(*conf) + // load configCircuits devices from database + configurable, err := config.ConfigurationsByClass(templates.Circuit) + if err != nil { + return err + } + // device config from db + if yamlSource.circuits != globalconfig.YamlSourceNone && len(configurable) > 0 { + return errors.New("circuits are configured via UI; having an additional yaml config is not allowed") + } + + if err := validateStaticCircuits(slices.Clone(*conf)); err != nil { + return err + } + if err := validateConfigurableCircuits(configurable); err != nil { + return err + } + + for _, c := range configurable { + *conf = append(*conf, c.Named()) + } + + return nil +} + +// validateCircuitConfigs validates circuit configurations with support for both static and configurable types +func validateCircuitConfigs[T any](children []T, getNamedConfig func(T) config.Named, getDevice func(T, api.Circuit) config.Device[api.Circuit]) error { // TODO check for circular references NEXT: - for i, cc := range children { + for i, child := range children { + cc := getNamedConfig(child) + if cc.Name == "" { return fmt.Errorf("cannot create circuit: missing name") } @@ -184,14 +222,14 @@ NEXT: } } - log := util.NewLogger("circuit-" + cc.Name) - props, err := customDevice(cc.Other) if err != nil { return fmt.Errorf("cannot decode custom circuit '%s': %w", cc.Name, err) } - instance, err := circuit.NewFromConfig(context.TODO(), log, props) + ctx := util.WithLogger(context.TODO(), util.NewLogger(cc.Name)) + + instance, err := circuit.NewFromConfig(ctx, cc.Type, props) if err != nil { return fmt.Errorf("cannot create circuit '%s': %w", cc.Name, err) } @@ -202,7 +240,7 @@ NEXT: instance.SetTitle(strings.Title(cc.Name)) } - if err := config.Circuits().Add(config.NewStaticDevice(cc, instance)); err != nil { + if err := config.Circuits().Add(getDevice(child, instance)); err != nil { return err } @@ -211,7 +249,8 @@ NEXT: } if len(children) > 0 { - return fmt.Errorf("circuit is missing parent: %s", children[0].Name) + cn := getNamedConfig(children[0]) + return fmt.Errorf("circuit is missing parent: %s", cn.Name) } var rootFound bool @@ -233,6 +272,26 @@ NEXT: return nil } +func validateStaticCircuits(children []config.Named) error { + return validateCircuitConfigs( + children, + func(cc config.Named) config.Named { return cc }, + func(cc config.Named, instance api.Circuit) config.Device[api.Circuit] { + return config.NewStaticDevice(cc, instance) + }, + ) +} + +func validateConfigurableCircuits(children []config.Config) error { + return validateCircuitConfigs( + children, + func(cc config.Config) config.Named { return cc.Named() }, + func(cc config.Config, instance api.Circuit) config.Device[api.Circuit] { + return config.NewConfigurableDevice(&cc, instance) + }, + ) +} + type newFromConfFunc[T any] func(context.Context, string, map[string]any) (T, error) func staticInstance[T any](typ string, cc config.Named, newFromConf newFromConfFunc[T], h config.Handler[T]) error { diff --git a/core/circuit/circuit.go b/core/circuit/circuit.go index eedd8f187..d31670bdf 100644 --- a/core/circuit/circuit.go +++ b/core/circuit/circuit.go @@ -43,8 +43,12 @@ type Circuit struct { powerUpdated time.Time } -// NewFromConfig creates a new Circuit -func NewFromConfig(ctx context.Context, log *util.Logger, other map[string]any) (api.Circuit, error) { +func init() { + registry.AddCtx(api.Custom, NewConfigurableFromConfig) +} + +// NewConfigurableFromConfig creates a new circuit from config +func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.Circuit, error) { cc := struct { Title string // title ParentRef string `mapstructure:"parent"` // parent circuit reference @@ -58,6 +62,9 @@ func NewFromConfig(ctx context.Context, log *util.Logger, other map[string]any) Timeout: time.Minute, } + // drop circuit type- all circuits are custom + delete(other, "type") + if err := util.DecodeOther(other, &cc); err != nil { return nil, err } @@ -74,6 +81,7 @@ func NewFromConfig(ctx context.Context, log *util.Logger, other map[string]any) } } + log := util.ContextLoggerWithDefault(ctx, util.NewLogger("circuit")) circuit, err := New(log, cc.Title, cc.MaxCurrent, cc.MaxPower, meter, cc.Timeout) if err != nil { return nil, err diff --git a/core/circuit/config.go b/core/circuit/config.go index 30af21a74..013f8e686 100644 --- a/core/circuit/config.go +++ b/core/circuit/config.go @@ -1,10 +1,38 @@ package circuit import ( + "context" + "fmt" + "strings" + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/config" + reg "github.com/evcc-io/evcc/util/registry" ) +var registry = reg.New[api.Circuit]("circuit") + +// NewFromConfig creates api.Circuit from configuration +func NewFromConfig(ctx context.Context, typ string, other map[string]any) (api.Circuit, error) { + // treat any non-template circuit as custom in order for registry lookup to work + if typ == "" { + typ = api.Custom + } + + factory, err := registry.Get(strings.ToLower(typ)) + if err != nil { + return nil, err + } + + v, err := factory(ctx, other) + if err != nil { + err = fmt.Errorf("cannot create circuit type '%s': %w", util.TypeWithTemplateName(typ, other), err) + } + + return v, err +} + func Root() api.Circuit { for _, dev := range config.Circuits().Devices() { if c := dev.Instance(); c.GetParent() == nil { diff --git a/core/circuit/template.go b/core/circuit/template.go new file mode 100644 index 000000000..7f6d68f25 --- /dev/null +++ b/core/circuit/template.go @@ -0,0 +1,21 @@ +package circuit + +import ( + "context" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util/templates" +) + +func init() { + registry.AddCtx("template", NewFromTemplateConfig) +} + +func NewFromTemplateConfig(ctx context.Context, other map[string]any) (api.Circuit, error) { + instance, err := templates.RenderInstance(templates.Circuit, other) + if err != nil { + return nil, err + } + + return NewConfigurableFromConfig(ctx, instance.Other) +} diff --git a/messenger/messenger.go b/messenger/messenger.go index 59e6dc613..81bbf24e9 100644 --- a/messenger/messenger.go +++ b/messenger/messenger.go @@ -16,7 +16,7 @@ func init() { registry.AddCtx(api.Custom, NewConfigurableFromConfig) } -// NewConfigurableFromConfig creates Messenger from config +// NewConfigurableFromConfig creates a new messenger from config func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.Messenger, error) { var cc struct { Send plugin.Config diff --git a/meter/meter.go b/meter/meter.go index 12a5290fd..6194f2018 100644 --- a/meter/meter.go +++ b/meter/meter.go @@ -24,7 +24,7 @@ func init() { //evcc:basetype api.Meter //evcc:types api.MeterEnergy,api.Battery,api.BatteryCapacity,api.BatterySocLimiter,api.BatteryPowerLimiter,api.BatteryController -// NewConfigurableFromConfig creates api.Meter from config +// NewConfigurableFromConfig creates a new meter from config func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.Meter, error) { cc := struct { measurement.Energy `mapstructure:",squash"` // energy optional diff --git a/server/http_config_device_handler.go b/server/http_config_device_handler.go index 391c28158..9e0eace53 100644 --- a/server/http_config_device_handler.go +++ b/server/http_config_device_handler.go @@ -11,7 +11,6 @@ import ( "strconv" "dario.cat/mergo" - "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/api/globalconfig" "github.com/evcc-io/evcc/charger" "github.com/evcc-io/evcc/core/circuit" @@ -21,7 +20,6 @@ import ( "github.com/evcc-io/evcc/meter" "github.com/evcc-io/evcc/server/db/settings" "github.com/evcc-io/evcc/tariff" - "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/config" "github.com/evcc-io/evcc/util/templates" "github.com/evcc-io/evcc/vehicle" @@ -327,9 +325,7 @@ func newDeviceHandler(w http.ResponseWriter, r *http.Request) { conf, err = newDevice(ctx, class, req, vehicle.NewFromConfig, config.Vehicles(), force) case templates.Circuit: - conf, err = newDevice(ctx, class, req, func(ctx context.Context, _ string, other map[string]any) (api.Circuit, error) { - return circuit.NewFromConfig(ctx, util.NewLogger("circuit"), other) - }, config.Circuits(), force) + conf, err = newDevice(ctx, class, req, circuit.NewFromConfig, config.Circuits(), force) case templates.Tariff: conf, err = newDevice(ctx, class, req, tariff.NewFromConfig, config.Tariffs(), force) @@ -414,9 +410,7 @@ func updateDeviceHandler(w http.ResponseWriter, r *http.Request) { err = updateDevice(ctx, id, class, req, vehicle.NewFromConfig, config.Vehicles(), force) case templates.Circuit: - err = updateDevice(ctx, id, class, req, func(ctx context.Context, _ string, other map[string]any) (api.Circuit, error) { - return circuit.NewFromConfig(ctx, util.NewLogger("circuit"), other) - }, config.Circuits(), force) + err = updateDevice(ctx, id, class, req, circuit.NewFromConfig, config.Circuits(), force) case templates.Tariff: err = updateDevice(ctx, id, class, req, tariff.NewFromConfig, config.Tariffs(), force) @@ -670,7 +664,7 @@ func testConfigHandler(w http.ResponseWriter, r *http.Request) { instance, err = testConfig(ctx, id, class, req, vehicle.NewFromConfig, config.Vehicles()) case templates.Circuit: - err = api.ErrNotAvailable + instance, err = testConfig(ctx, id, class, req, circuit.NewFromConfig, config.Circuits()) case templates.Tariff: instance, err = testConfig(ctx, id, class, req, tariff.NewFromConfig, config.Tariffs()) diff --git a/templates/definition/circuit/static.yaml b/templates/definition/circuit/static.yaml new file mode 100644 index 000000000..b83f2e594 --- /dev/null +++ b/templates/definition/circuit/static.yaml @@ -0,0 +1,33 @@ +template: static +products: + - description: + en: Static circuit + de: Statischer Stromkreis +params: + - name: title + required: true + example: Main circuit + - name: maxcurrent + help: + de: Vom Lastmanagement maximal erlaubte Stromstärke pro Phase. + en: Maximum current per phase allowed by the load management system. + - name: maxpower + description: + de: Maximale Leistung + en: Maximum power + help: + de: Vom Lastmanagement maximal erlaubte Leistung. Das System verteilt verfügbare Leistung dynamisch auf alle angeschlossenen Ladepunkte und Schaltungen. + en: Maximum power allowed by the load management system. The system dynamically distributes available power across all connected chargers and circuits. + - name: meter + # service: TODO + - name: parent + description: + de: Übergeordneter Stromkreis + en: Parent circuit +render: | + type: custom + title: {{ .title }} + maxcurrent: {{ .maxcurrent }} + maxpower: {{ .maxpower }} + meter: {{ .meter }} + parent: {{ .parent }} diff --git a/templates/definition/embed.go b/templates/definition/embed.go index e7e925872..4a147796b 100644 --- a/templates/definition/embed.go +++ b/templates/definition/embed.go @@ -2,5 +2,5 @@ package definition import "embed" -//go:embed charger/*.yaml meter/*.yaml vehicle/*.yaml tariff/*.yaml messenger/*.yaml +//go:embed charger/*.yaml meter/*.yaml vehicle/*.yaml tariff/*.yaml messenger/*.yaml circuit/*.yaml var YamlTemplates embed.FS diff --git a/tests/config-circuit-device.spec.ts b/tests/config-circuit-device.spec.ts new file mode 100644 index 000000000..2c4ff42f5 --- /dev/null +++ b/tests/config-circuit-device.spec.ts @@ -0,0 +1,133 @@ +// Temporary API-level tests to validate circuit device CRUD endpoints. +// Will be replaced by UI-based tests once the circuit configuration UI is implemented. + +import { test, expect } from "@playwright/test"; +import { start, stop, restart, baseUrl } from "./evcc"; + +test.use({ baseURL: baseUrl() }); +test.describe.configure({ mode: "parallel" }); + +test.beforeEach(async () => { + await start(); +}); + +test.afterEach(async () => { + await stop(); +}); + +test.describe("circuit device api", () => { + test("crud lifecycle and restart persistence", async ({ page }) => { + // create root circuit + const createRoot = await page.request.post("/api/config/devices/circuit", { + data: { + type: "template", + template: "static", + title: "Main", + maxcurrent: 32, + }, + }); + expect(createRoot.status()).toBe(200); + const root = await createRoot.json(); + expect(root).toHaveProperty("id"); + expect(root).toHaveProperty("name"); + + // create child circuit with parent + const createChild = await page.request.post("/api/config/devices/circuit", { + data: { + type: "template", + template: "static", + title: "Garage", + maxcurrent: 16, + parent: root.name, + }, + }); + expect(createChild.status()).toBe(200); + const child = await createChild.json(); + expect(child).toHaveProperty("id"); + + // list circuits + const list = await page.request.get("/api/config/devices/circuit"); + expect(list.status()).toBe(200); + const listBody = await list.json(); + expect(listBody).toHaveLength(2); + + // get single + const get = await page.request.get(`/api/config/devices/circuit/${root.id}`); + expect(get.status()).toBe(200); + const getBody = await get.json(); + expect(getBody).toHaveProperty("type", "template"); + expect(getBody).toHaveProperty("name", root.name); + + // update + const update = await page.request.put(`/api/config/devices/circuit/${root.id}`, { + data: { + type: "template", + template: "static", + title: "Main Updated", + maxcurrent: 48, + }, + }); + expect(update.status()).toBe(200); + + // verify update + const getUpdated = await page.request.get(`/api/config/devices/circuit/${root.id}`); + const updatedBody = await getUpdated.json(); + expect(updatedBody.config.title).toBe("Main Updated"); + expect(updatedBody.config.maxcurrent).toBe(48); + + // delete child + const del = await page.request.delete(`/api/config/devices/circuit/${child.id}`); + expect(del.status()).toBe(200); + + // verify deleted + const listAfter = await page.request.get("/api/config/devices/circuit"); + const listAfterBody = await listAfter.json(); + expect(listAfterBody).toHaveLength(1); + + // restart and verify persistence + await restart(); + + const listRestart = await page.request.get("/api/config/devices/circuit"); + expect(listRestart.status()).toBe(200); + const restartBody = await listRestart.json(); + expect(restartBody).toHaveLength(1); + }); + + test("error handling", async ({ page }) => { + // get non-existent circuit + const get = await page.request.get("/api/config/devices/circuit/99"); + expect(get.status()).toBe(400); + + // delete non-existent circuit + const del = await page.request.delete("/api/config/devices/circuit/99"); + expect(del.status()).toBe(400); + + // create with invalid template + const invalid = await page.request.post("/api/config/devices/circuit", { + data: { + type: "template", + template: "nonexistent", + title: "Bad", + }, + }); + expect(invalid.ok()).toBeFalsy(); + }); + + test("custom type circuit", async ({ page }) => { + // create circuit via custom type with raw YAML + const create = await page.request.post("/api/config/devices/circuit", { + data: { + type: "custom", + yaml: "type: custom\ntitle: Custom Circuit\nmaxCurrent: 24\n", + }, + }); + expect(create.status()).toBe(200); + const result = await create.json(); + expect(result).toHaveProperty("id"); + + // verify it exists + const list = await page.request.get("/api/config/devices/circuit"); + const body = await list.json(); + expect(body).toHaveLength(1); + }); +}); diff --git a/util/templates/defaults.yaml b/util/templates/defaults.yaml index 0900c5bc2..698cd85e5 100644 --- a/util/templates/defaults.yaml +++ b/util/templates/defaults.yaml @@ -182,6 +182,7 @@ params: de: Definiert die maximale Ladeleistung des Fahrzeugs. Hilft, die Ladeplanung zu verbessern, wenn das Fahrzeug üblicherweise weniger Strom nutzt als angeboten oder höhere Ströme bei einphasigem Laden erlaubt als bei dreiphasigem. _Der bereitgestellte Strom des Ladepunktes wird nicht beeinflusst._ en: Defines the maximum charging power of the vehicle. Helps improve charge plan accuracy when the vehicle typically uses less than the offered current or supports higher single-phase current compared to three-phase. _The offered current of the loadpoint is not affected._ type: int + example: 10000 - name: identifiers description: de: Identifikation diff --git a/util/templates/init.go b/util/templates/init.go index aad947df5..9fbfa53bb 100644 --- a/util/templates/init.go +++ b/util/templates/init.go @@ -33,7 +33,7 @@ func init() { baseTmpl = template.Must(FuncMap(template.New("base")).ParseFS(includeFS, "includes/*.tpl")) - for _, class := range []Class{Charger, Meter, Vehicle, Tariff, Messenger} { + for _, class := range []Class{Charger, Meter, Vehicle, Tariff, Messenger, Circuit} { load(class) } } diff --git a/vehicle/vehicle.go b/vehicle/vehicle.go index 37f80a4f2..5da4c7ab9 100644 --- a/vehicle/vehicle.go +++ b/vehicle/vehicle.go @@ -23,7 +23,7 @@ func init() { registry.AddCtx(api.Custom, NewConfigurableFromConfig) } -// NewConfigurableFromConfig creates a new Vehicle +// NewConfigurableFromConfig creates a new vehicle from config func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.Vehicle, error) { var cc struct { embed `mapstructure:",squash"`