Config UI: make circuits configurable (#27842)
This commit is contained in:
parent
7ffe07af54
commit
db11f1d7c5
14 changed files with 302 additions and 25 deletions
|
|
@ -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"`
|
||||
|
|
|
|||
75
cmd/setup.go
75
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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
21
core/circuit/template.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
33
templates/definition/circuit/static.yaml
Normal file
33
templates/definition/circuit/static.yaml
Normal 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 }}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
133
tests/config-circuit-device.spec.ts
Normal file
133
tests/config-circuit-device.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue