Config UI: make circuits configurable (#27842)

This commit is contained in:
Maschga 2026-04-13 13:08:53 +02:00 • committed by GitHub
parent 7ffe07af54
commit db11f1d7c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 302 additions and 25 deletions

View file

@ -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"`

View file

@ -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 {

View file

@ -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

View file

@ -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 {

21
core/circuit/template.go Normal file
View file

@ -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)
}

View file

@ -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

View file

@ -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

View file

@ -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())

View file

@ -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 }}

View file

@ -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

View file

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

View file

@ -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

View file

@ -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)
}
}

View file

@ -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"`