Templates: support class-local include files (#32871)

This commit is contained in:
andig 2026-08-18 09:23:03 +02:00 • committed by GitHub
parent 9034f45355
commit de3f8995cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
116 changed files with 285 additions and 212 deletions

View file

@ -31,7 +31,7 @@ func TestRequiredLargeNumber(t *testing.T) {
Params: []Param{{Name: "serial", Type: TypeInt, Required: true}},
}
_, _, err := tmpl.RenderResult(RenderModeUnitTest, map[string]any{
_, _, err := tmpl.RenderResult(Meter, RenderModeUnitTest, map[string]any{
"serial": float64(3493601102),
})
assert.NoError(t, err, "large serial supplied as JSON number")

View file

@ -1,19 +0,0 @@
{{ define "battery-capacity" }}
capacity: {{ .capacity }} # kWh
{{- end }}
{{ define "battery-minmaxsoc" }}
minsoc: {{ .minsoc }} # %
maxsoc: {{ .maxsoc }} # %
{{- end }}
{{ define "battery-power" }}
maxchargepower: {{ .maxchargepower }} # W
maxdischargepower: {{ .maxdischargepower }} # W
{{- end }}
{{ define "battery-params" }}
{{- include "battery-capacity" . }}
{{- include "battery-minmaxsoc" . }}
{{- include "battery-power" . }}
{{- end }}

View file

@ -1,11 +0,0 @@
{{ define "charger-features" }}
{{- if or (eq .heating "true") (eq .integrateddevice "true") }}
features:
{{- if eq .heating "true" }}
- heating
{{- end }}
{{- if eq .integrateddevice "true" }}
- integrateddevice
{{- end }}
{{- end }}
{{- end }}

View file

@ -1,7 +0,0 @@
{{ define "heatpumpswitch" }}
features:
- continuous
- heating
- integrateddevice
- switchdevice
{{- end }}

View file

@ -1,27 +0,0 @@
{{ define "ocpp" }}
type: ocpp
{{- if .stationid }}
stationid: {{ .stationid }}
{{- end }}
{{- if ne .connector "1" }}
connector: {{ .connector }}
{{- end }}
{{- if .idtag }}
idtag: {{ .idtag }}
{{- end }}
{{- if and .remotestart (ne .remotestart "false") }}
remotestart: {{ .remotestart }}
{{- end }}
{{- if .metervalues }}
metervalues: {{ .metervalues }}
{{- end }}
{{- if and .meterinterval (ne .meterinterval "10s") }}
meterinterval: {{ .meterinterval }}
{{- end }}
{{- if ne .connecttimeout "5m" }}
connecttimeout: {{ .connecttimeout }}
{{- end }}
{{- if and .timeout (ne .timeout "30s") }}
timeout: {{ .timeout }}
{{- end }}
{{- end }}

View file

@ -1,14 +0,0 @@
{{ define "switchsocket" }}
standbypower: {{ .standbypower }}
features:
- switchdevice
{{- if and .integrateddevice (ne .integrateddevice "false") }}
- integrateddevice
{{- end }}
{{- if and .heating (ne .heating "false") }}
- heating
{{- end }}
{{- if .icon }}
icon: {{ .icon }}
{{- end }}
{{- end }}

View file

@ -1,26 +0,0 @@
{{ define "tariff-base" }}
{{- if .charges }}
charges: {{ .charges }}
{{- end }}
{{- if .chargesZones }}
chargesZones:
{{- range .chargesZones }}
- charges: {{ .price }}
{{- if .days }}
days: {{ .days }}
{{- end }}
{{- if .hours }}
hours: {{ .hours }}
{{- end }}
{{- if .months }}
months: {{ .months }}
{{- end }}
{{- end }}
{{- end }}
{{- if .tax }}
tax: {{ .tax }}
{{- end }}
{{- if .formula }}
formula: {{ .formula }}
{{- end }}
{{- end }}

View file

@ -1,5 +0,0 @@
{{ define "tariff-features" }}
{{- if eq .average "true" }}
features: ["average"]
{{- end }}
{{- end }}

View file

@ -1,9 +0,0 @@
{{ define "vehicle-base" }}
user: {{ .user }}
password: {{ .password }}
vin: {{ .vin }}
{{ template "vehicle-common" . }}
{{- if .cache }}
cache: {{ .cache }}
{{- end }}
{{- end }}

View file

@ -1,41 +0,0 @@
{{ define "vehicle-common" }}
{{- if .title }}
title: {{ .title }}
{{- end }}
{{- if .icon }}
icon: {{ .icon }}
{{- end }}
{{- if .capacity }}
capacity: {{ .capacity }}
{{- end }}
{{- if .phases }}
phases: {{ .phases }}
{{- end }}
{{- if or .mode .minCurrent .maxCurrent .maxPower .priority }}
onIdentify:
{{- if .mode }}
mode: {{ .mode }}
{{- end }}
{{- if .minCurrent }}
minCurrent: {{ .minCurrent }}
{{- end }}
{{- if .maxCurrent }}
maxCurrent: {{ .maxCurrent }}
{{- end }}
{{- if .maxPower }}
maxPower: {{ .maxPower }}
{{- end }}
{{- if .priority }}
priority: {{ .priority }}
{{- end }}
{{- end }}
{{- if len .identifiers }}
identifiers:
{{- range .identifiers }}
- {{ quote . }}
{{- end }}
{{- end }}
{{- end }}

View file

@ -1,26 +0,0 @@
{{ define "vehicle-features" }}
{{- if or .basefeatures (eq .coarsecurrent "true") (eq .welcomecharge "true") (eq .streaming "true") (eq .climaterdisabled "true") (eq .autodetectdisabled "true") (eq .wakeupdisabled "true") }}
features:
{{- range .basefeatures }}
- {{ . }}
{{- end }}
{{- if eq .coarsecurrent "true" }}
- coarsecurrent
{{- end }}
{{- if eq .welcomecharge "true" }}
- welcomecharge
{{- end }}
{{- if eq .streaming "true" }}
- streaming
{{- end }}
{{- if eq .climaterdisabled "true" }}
- climaterdisabled
{{- end }}
{{- if eq .autodetectdisabled "true" }}
- autodetectdisabled
{{- end }}
{{- if eq .wakeupdisabled "true" }}
- wakeupdisabled
{{- end }}
{{- end }}
{{- end }}

View file

@ -1,3 +0,0 @@
{{ define "vehicle-language" }}
language: {{ .language }}
{{- end }}

View file

@ -15,6 +15,9 @@ import (
"go.yaml.in/yaml/v4"
)
// IncludeExt is the file extension of class-local, include-only templates
const IncludeExt = ".tpl"
var (
//go:embed includes/*.tpl
includeFS embed.FS
@ -22,6 +25,9 @@ var (
// baseTmpl holds all included template definitions
baseTmpl *template.Template
// classTmpl holds baseTmpl plus the class-local include definitions
classTmpl = make(map[Class]*template.Template)
templates = make(map[Class][]Template)
ConfigDefaults configDefaults
mu sync.Mutex
@ -31,13 +37,35 @@ var (
func init() {
ConfigDefaults.Load()
baseTmpl = template.Must(FuncMap(template.New("base")).ParseFS(includeFS, "includes/*.tpl"))
// the root template is replaced by the rendered template's body, so its name
// must not collide with an include name (`$` cannot appear in a file name)
baseTmpl = template.Must(FuncMap(template.New("$root")).ParseFS(includeFS, "includes/*.tpl"))
for _, class := range []Class{Charger, Meter, Vehicle, Tariff, Messenger, Circuit, Hems} {
if err := loadIncludes(definition.TemplateIncludes, class); err != nil {
panic(err)
}
load(class)
}
}
// loadIncludes parses the class-local include-only templates
func loadIncludes(fsys fs.FS, class Class) error {
files, err := fs.Glob(fsys, class.String()+"/*"+IncludeExt)
if err != nil || len(files) == 0 {
return err
}
tmpl, err := FuncMap(template.Must(baseTmpl.Clone())).ParseFS(fsys, files...)
if err != nil {
return fmt.Errorf("processing %s includes failed: %w", class, err)
}
classTmpl[class] = tmpl
return nil
}
// Register adds a template file to the registry
func Register(class Class, filepath string) error {
b, err := os.ReadFile(filepath)

View file

@ -0,0 +1,36 @@
package templates
import (
"testing"
"testing/fstest"
"github.com/stretchr/testify/require"
)
func TestClassLocalIncludes(t *testing.T) {
fsys := fstest.MapFS{
"loadpoint/local" + IncludeExt: &fstest.MapFile{
Data: []byte(`{{ define "local" }}power: {{ .foo }}{{ end }}`),
},
}
require.NoError(t, loadIncludes(fsys, Loadpoint))
require.Contains(t, classTmpl, Loadpoint)
// class without include files
require.NoError(t, loadIncludes(fsys, Circuit))
require.NotContains(t, classTmpl, Circuit)
tmpl := Template{
Params: []Param{{Name: "foo", Default: "bar"}},
Render: "type: custom\n{{ include \"local\" . }}",
}
b, _, err := tmpl.RenderResult(Loadpoint, RenderModeInstance, map[string]any{})
require.NoError(t, err)
require.Equal(t, "type: custom\npower: bar", string(b))
// class-local includes are not visible to other classes
_, _, err = tmpl.RenderResult(Circuit, RenderModeInstance, map[string]any{})
require.Error(t, err)
}

View file

@ -31,7 +31,7 @@ func RenderInstance(class Class, other map[string]any) (*Instance, error) {
return nil, err
}
b, _, err := tmpl.RenderResult(RenderModeInstance, other)
b, _, err := tmpl.RenderResult(class, RenderModeInstance, other)
if err != nil {
return nil, util.NewConfigError(err)
}

View file

@ -13,10 +13,10 @@ import (
)
// test renders and instantiates plus yaml-parses the template per usage
func test(t *testing.T, tmpl Template, values map[string]any, cb func(values map[string]any)) {
func test(t *testing.T, class Class, tmpl Template, values map[string]any, cb func(values map[string]any)) {
t.Helper()
b, _, err := tmpl.RenderResult(RenderModeInstance, values)
b, _, err := tmpl.RenderResult(class, RenderModeInstance, values)
if err != nil {
t.Log(string(b))
t.Error(err)
@ -108,7 +108,7 @@ func TestClass(t *testing.T, class Class, instantiate func(t *testing.T, values
t.Run(tmpl.Template, func(t *testing.T) {
t.Parallel()
test(t, tmpl, values, func(values map[string]any) {
test(t, class, tmpl, values, func(values map[string]any) {
instantiate(t, values)
})
})
@ -125,7 +125,7 @@ func TestClass(t *testing.T, class Class, instantiate func(t *testing.T, values
t.Run(tmpl.Template+"/"+u, func(t *testing.T) {
t.Parallel()
test(t, tmpl, usageValues, func(values map[string]any) {
test(t, class, tmpl, usageValues, func(values map[string]any) {
instantiate(t, values)
})
})

View file

@ -325,7 +325,7 @@ func formatValue(val any) string {
}
// RenderResult renders the result template to instantiate the proxy
func (t *Template) RenderResult(renderMode int, other map[string]any) ([]byte, map[string]any, error) {
func (t *Template) RenderResult(class Class, renderMode int, other map[string]any) ([]byte, map[string]any, error) {
values := t.Defaults(renderMode)
if err := mergeMaps(other, values); err != nil {
return nil, values, err
@ -422,7 +422,13 @@ func (t *Template) RenderResult(renderMode int, other map[string]any) ([]byte, m
}
}
tmpl, err := FuncMap(template.Must(baseTmpl.Clone())).Parse(t.Render)
// class-local includes take precedence over the global ones
base, ok := classTmpl[class]
if !ok {
base = baseTmpl
}
tmpl, err := FuncMap(template.Must(base.Clone())).Parse(t.Render)
if err != nil {
return nil, res, err
}

View file

@ -22,7 +22,7 @@ func TestModbusTemplateDefaultID(t *testing.T) {
tmpl, err := ByName(Charger, "phoenix-ev-eth")
require.NoError(t, err)
_, values, err := tmpl.RenderResult(mode, map[string]any{
_, values, err := tmpl.RenderResult(Meter, mode, map[string]any{
"host": "192.168.0.8",
"port": 502,
})
@ -40,7 +40,7 @@ func TestModbusTemplateUserIDOverridesTemplate(t *testing.T) {
tmpl, err := ByName(Charger, "phoenix-ev-eth")
require.NoError(t, err)
_, values, err := tmpl.RenderResult(mode, map[string]any{
_, values, err := tmpl.RenderResult(Meter, mode, map[string]any{
"host": "192.168.0.8",
"port": 502,
"id": 42,
@ -62,7 +62,7 @@ func TestWallbeTemplateCoveredByPhoenix(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "phoenix-ev-eth", tmpl.Template)
_, values, err := tmpl.RenderResult(RenderModeInstance, map[string]any{
_, values, err := tmpl.RenderResult(Meter, RenderModeInstance, map[string]any{
"host": "192.168.0.8",
"port": 502,
})

View file

@ -42,22 +42,22 @@ func TestRequiredString(t *testing.T) {
},
}
_, _, err := tmpl.RenderResult(RenderModeUnitTest, map[string]any{
_, _, err := tmpl.RenderResult(Meter, RenderModeUnitTest, map[string]any{
"Param": "foo",
})
assert.NoError(t, err, "test: required present")
_, _, err = tmpl.RenderResult(RenderModeUnitTest, map[string]any{
_, _, err = tmpl.RenderResult(Meter, RenderModeUnitTest, map[string]any{
"Param": "",
})
assert.Error(t, err, "test: required present but empty")
_, _, err = tmpl.RenderResult(RenderModeUnitTest, map[string]any{
_, _, err = tmpl.RenderResult(Meter, RenderModeUnitTest, map[string]any{
"Param": nil,
})
assert.Error(t, err, "test: required present but nil")
_, _, err = tmpl.RenderResult(RenderModeDocs, map[string]any{
_, _, err = tmpl.RenderResult(Meter, RenderModeDocs, map[string]any{
"Param": nil,
})
assert.NoError(t, err, "docs: required present but nil")
@ -74,27 +74,27 @@ func TestRequiredNumber(t *testing.T) {
},
}
_, _, err := tmpl.RenderResult(RenderModeUnitTest, map[string]any{
_, _, err := tmpl.RenderResult(Meter, RenderModeUnitTest, map[string]any{
"Param": "1",
})
assert.NoError(t, err, "test: required present")
_, _, err = tmpl.RenderResult(RenderModeUnitTest, map[string]any{
_, _, err = tmpl.RenderResult(Meter, RenderModeUnitTest, map[string]any{
"Param": "",
})
assert.Error(t, err, "test: required present but empty")
_, _, err = tmpl.RenderResult(RenderModeUnitTest, map[string]any{
_, _, err = tmpl.RenderResult(Meter, RenderModeUnitTest, map[string]any{
"Param": "0",
})
assert.Error(t, err, "test: required present but zero value")
_, _, err = tmpl.RenderResult(RenderModeUnitTest, map[string]any{
_, _, err = tmpl.RenderResult(Meter, RenderModeUnitTest, map[string]any{
"Param": nil,
})
assert.Error(t, err, "test: required present but nil")
_, _, err = tmpl.RenderResult(RenderModeDocs, map[string]any{
_, _, err = tmpl.RenderResult(Meter, RenderModeDocs, map[string]any{
"Param": nil,
})
assert.NoError(t, err, "docs: required present but nil")
@ -111,22 +111,22 @@ func TestRequiredDeprecated(t *testing.T) {
},
}
_, _, err := tmpl.RenderResult(RenderModeUnitTest, map[string]any{
_, _, err := tmpl.RenderResult(Meter, RenderModeUnitTest, map[string]any{
"Param": "foo",
})
assert.NoError(t, err, "test: required present")
_, _, err = tmpl.RenderResult(RenderModeUnitTest, map[string]any{
_, _, err = tmpl.RenderResult(Meter, RenderModeUnitTest, map[string]any{
"Param": "",
})
assert.NoError(t, err, "test: required present but empty")
_, _, err = tmpl.RenderResult(RenderModeUnitTest, map[string]any{
_, _, err = tmpl.RenderResult(Meter, RenderModeUnitTest, map[string]any{
"Param": nil,
})
assert.NoError(t, err, "test: required present but nil")
_, _, err = tmpl.RenderResult(RenderModeDocs, map[string]any{
_, _, err = tmpl.RenderResult(Meter, RenderModeDocs, map[string]any{
"Param": nil,
})
assert.NoError(t, err, "docs: required present but nil")
@ -146,25 +146,25 @@ func TestRequiredPerUsage(t *testing.T) {
},
}
_, _, err := tmpl.RenderResult(RenderModeUnitTest, map[string]any{
_, _, err := tmpl.RenderResult(Meter, RenderModeUnitTest, map[string]any{
"Param": nil,
"Usage": nil,
})
require.NoError(t, err)
_, _, err = tmpl.RenderResult(RenderModeUnitTest, map[string]any{
_, _, err = tmpl.RenderResult(Meter, RenderModeUnitTest, map[string]any{
"Param": nil,
"Usage": "pv",
})
require.NoError(t, err)
_, _, err = tmpl.RenderResult(RenderModeUnitTest, map[string]any{
_, _, err = tmpl.RenderResult(Meter, RenderModeUnitTest, map[string]any{
"Param": nil,
"Usage": "battery",
})
require.Error(t, err)
_, _, err = tmpl.RenderResult(RenderModeUnitTest, map[string]any{
_, _, err = tmpl.RenderResult(Meter, RenderModeUnitTest, map[string]any{
"Param": "foo",
"Usage": "battery",
})
@ -190,7 +190,7 @@ func TestValidatePattern(t *testing.T) {
for _, tt := range tests {
t.Run(tt.host, func(t *testing.T) {
_, _, err := tmpl.RenderResult(RenderModeInstance, map[string]any{"host": tt.host})
_, _, err := tmpl.RenderResult(Meter, RenderModeInstance, map[string]any{"host": tt.host})
if tt.valid {
require.NoError(t, err)
} else {