From 7079798d09f7a3be58182e17c06b53dda45dcfe4 Mon Sep 17 00:00:00 2001 From: premultiply <4681172+premultiply@users.noreply.github.com> Date: Sun, 19 Oct 2025 14:09:03 +0200 Subject: [PATCH] EEBus meter: add MPC/LPC use cases (#24082) --- meter/eebus.go | 171 +++++++++++++++++---- meter/eebus_test.go | 37 +++++ meter/template_test.go | 3 +- server/eebus/eebus.go | 5 +- templates/definition/meter/eebus-mcp.yaml | 30 ++++ templates/definition/meter/eebus-mgcp.yaml | 30 ++++ 6 files changed, 240 insertions(+), 36 deletions(-) create mode 100644 meter/eebus_test.go create mode 100644 templates/definition/meter/eebus-mcp.yaml create mode 100644 templates/definition/meter/eebus-mgcp.yaml diff --git a/meter/eebus.go b/meter/eebus.go index 7aa4ed5e3..01f2f49c6 100644 --- a/meter/eebus.go +++ b/meter/eebus.go @@ -6,32 +6,59 @@ import ( "time" eebusapi "github.com/enbility/eebus-go/api" + ucapi "github.com/enbility/eebus-go/usecases/api" "github.com/enbility/eebus-go/usecases/ma/mgcp" + "github.com/enbility/eebus-go/usecases/ma/mpc" spineapi "github.com/enbility/spine-go/api" "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/server/eebus" "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/templates" ) +// EEBus is an EEBus meter implementation supporting MGCP, MPC, and LPC use cases +// Uses MGCP (Monitoring of Grid Connection Point) only when usage="grid" +// Uses MPC (Monitoring & Power Consumption) for all other cases (default) +// Additionally supports LPC (Limitation of Power Consumption) type EEBus struct { log *util.Logger *eebus.Connector - uc *eebus.UseCasesCS + uc *eebus.UseCasesCS + api monitoringAPI - power, energy *util.Value[float64] - voltages, currents *util.Value[[]float64] + power *util.Value[float64] + energy *util.Value[float64] + currents *util.Value[[]float64] + voltages *util.Value[[]float64] +} + +// monitoringAPI provides a unified interface for MGCP and MPC use cases +type monitoringAPI struct { + measurements + powerEvent eebusapi.EventType + energyEvent eebusapi.EventType + currentEvent eebusapi.EventType + voltageEvent eebusapi.EventType +} + +type measurements interface { + Power(entity spineapi.EntityRemoteInterface) (float64, error) + EnergyConsumed(entity spineapi.EntityRemoteInterface) (float64, error) + CurrentPerPhase(entity spineapi.EntityRemoteInterface) ([]float64, error) + VoltagePerPhase(entity spineapi.EntityRemoteInterface) ([]float64, error) } func init() { registry.AddCtx("eebus", NewEEBusFromConfig) } -// New creates an EEBus HEMS from generic config +// NewEEBusFromConfig creates an EEBus meter from generic config func NewEEBusFromConfig(ctx context.Context, other map[string]interface{}) (api.Meter, error) { cc := struct { Ski string Ip string + Usage *templates.Usage Timeout time.Duration }{ Timeout: 10 * time.Second, @@ -41,23 +68,48 @@ func NewEEBusFromConfig(ctx context.Context, other map[string]interface{}) (api. return nil, err } - return NewEEBus(ctx, cc.Ski, cc.Ip, cc.Timeout) + return NewEEBus(ctx, cc.Ski, cc.Ip, cc.Usage, cc.Timeout) } -// NewEEBus creates EEBus charger -func NewEEBus(ctx context.Context, ski, ip string, timeout time.Duration) (*EEBus, error) { +// NewEEBus creates an EEBus meter +// Uses MGCP only when usage="grid", otherwise uses MPC (default) +func NewEEBus(ctx context.Context, ski, ip string, usage *templates.Usage, timeout time.Duration) (api.Meter, error) { if eebus.Instance == nil { return nil, errors.New("eebus not configured") } + cs := eebus.Instance.ControllableSystem() + + // Use MGCP only for explicit grid usage, MPC for everything else (default) + useCase := "mpc" + api := monitoringAPI{ + measurements: cs.MPC, + powerEvent: mpc.DataUpdatePower, + energyEvent: mpc.DataUpdateEnergyConsumed, + currentEvent: mpc.DataUpdateCurrentsPerPhase, + voltageEvent: mpc.DataUpdateVoltagePerPhase, + } + + if usage != nil && *usage == templates.UsageGrid { + useCase = "mgcp" + api = monitoringAPI{ + measurements: cs.MGCP, + powerEvent: mgcp.DataUpdatePower, + energyEvent: mgcp.DataUpdateEnergyConsumed, + currentEvent: mgcp.DataUpdateCurrentPerPhase, + voltageEvent: mgcp.DataUpdateVoltagePerPhase, + } + } + c := &EEBus{ - log: util.NewLogger("eebus"), - uc: eebus.Instance.ControllableSystem(), + log: util.NewLogger("eebus-" + useCase), + uc: cs, + api: api, Connector: eebus.NewConnector(), power: util.NewValue[float64](timeout), energy: util.NewValue[float64](timeout), - voltages: util.NewValue[[]float64](timeout), currents: util.NewValue[[]float64](timeout), + voltages: util.NewValue[[]float64](timeout), } if err := eebus.Instance.RegisterDevice(ski, ip, c); err != nil { @@ -76,80 +128,131 @@ var _ eebus.Device = (*EEBus)(nil) // UseCaseEvent implements the eebus.Device interface func (c *EEBus) UseCaseEvent(_ spineapi.DeviceRemoteInterface, entity spineapi.EntityRemoteInterface, event eebusapi.EventType) { + c.log.TRACE.Printf("recv: %s", event) + switch event { - case mgcp.DataUpdatePower: + case c.api.powerEvent: c.dataUpdatePower(entity) - case mgcp.DataUpdateEnergyConsumed: + case c.api.energyEvent: c.dataUpdateEnergyConsumed(entity) - case mgcp.DataUpdateCurrentPerPhase: + case c.api.currentEvent: c.dataUpdateCurrentPerPhase(entity) - case mgcp.DataUpdateVoltagePerPhase: + case c.api.voltageEvent: c.dataUpdateVoltagePerPhase(entity) } } func (c *EEBus) dataUpdatePower(entity spineapi.EntityRemoteInterface) { - data, err := c.uc.MGCP.Power(entity) + data, err := c.api.Power(entity) if err != nil { - c.log.ERROR.Println("MGCP.Power:", err) + c.log.ERROR.Println("Power:", err) return } + c.log.TRACE.Printf("Power: %.0fW", data) c.power.Set(data) } func (c *EEBus) dataUpdateEnergyConsumed(entity spineapi.EntityRemoteInterface) { - data, err := c.uc.MGCP.EnergyConsumed(entity) + data, err := c.api.EnergyConsumed(entity) if err != nil { - c.log.ERROR.Println("MGCP.EnergyConsumed:", err) + c.log.ERROR.Println("EnergyConsumed:", err) return } - c.energy.Set(data) + c.log.TRACE.Printf("EnergyConsumed: %.1fkWh", data/1000) + // Convert Wh to kWh + c.energy.Set(data / 1000) } func (c *EEBus) dataUpdateCurrentPerPhase(entity spineapi.EntityRemoteInterface) { - data, err := c.uc.MGCP.CurrentPerPhase(entity) + data, err := c.api.CurrentPerPhase(entity) if err != nil { - c.log.ERROR.Println("MGCP.CurrentPerPhase:", err) + c.log.ERROR.Println("CurrentPerPhase:", err) return } c.currents.Set(data) } func (c *EEBus) dataUpdateVoltagePerPhase(entity spineapi.EntityRemoteInterface) { - data, err := c.uc.MGCP.VoltagePerPhase(entity) + data, err := c.api.VoltagePerPhase(entity) if err != nil { - c.log.ERROR.Println("MGCP.VoltagePerPhase:", err) + c.log.ERROR.Println("VoltagePerPhase:", err) return } c.voltages.Set(data) } +var _ api.Meter = (*EEBus)(nil) + func (c *EEBus) CurrentPower() (float64, error) { return c.power.Get() } +var _ api.MeterEnergy = (*EEBus)(nil) + func (c *EEBus) TotalEnergy() (float64, error) { - return c.energy.Get() + res, err := c.energy.Get() + if err != nil { + return 0, api.ErrNotAvailable + } + + return res, nil } -func (c *EEBus) PhaseCurrents() (float64, float64, float64, error) { +var _ api.PhaseCurrents = (*EEBus)(nil) + +func (c *EEBus) Currents() (float64, float64, float64, error) { res, err := c.currents.Get() - if err == nil && len(res) != 3 { - err = errors.New("invalid phase currents") - } if err != nil { - return 0, 0, 0, err + return 0, 0, 0, api.ErrNotAvailable + } + if len(res) != 3 { + return 0, 0, 0, errors.New("invalid phase currents") } return res[0], res[1], res[2], nil } -func (c *EEBus) PhaseVoltages() (float64, float64, float64, error) { +var _ api.PhaseVoltages = (*EEBus)(nil) + +func (c *EEBus) Voltages() (float64, float64, float64, error) { res, err := c.voltages.Get() - if err == nil && len(res) != 3 { - err = errors.New("invalid phase voltages") - } if err != nil { - return 0, 0, 0, err + return 0, 0, 0, api.ErrNotAvailable + } + if len(res) != 3 { + return 0, 0, 0, errors.New("invalid phase voltages") } return res[0], res[1], res[2], nil } + +var _ api.Dimmer = (*EEBus)(nil) + +// Dimmed implements the api.Dimmer interface +func (c *EEBus) Dimmed() (bool, error) { + limit, err := c.uc.LPC.ConsumptionLimit() + if err != nil { + // No limit available means not dimmed + return false, nil + } + + // Check if limit is active and has a valid power value + return limit.IsActive && limit.Value > 0, nil +} + +// Dim implements the api.Dimmer interface +func (c *EEBus) Dim(dim bool) error { + // Sets or removes the consumption power limit + + // TODO: change api.Dimmer to make limit configurable + // For now, we use a fixed safe limit of 0W + limit := 0.0 + + var value float64 + if dim { + value = limit + } + + return c.uc.LPC.SetConsumptionLimit(ucapi.LoadLimit{ + Value: value, + IsActive: dim, + }) +} diff --git a/meter/eebus_test.go b/meter/eebus_test.go new file mode 100644 index 000000000..59bab44b2 --- /dev/null +++ b/meter/eebus_test.go @@ -0,0 +1,37 @@ +package meter + +import ( + "context" + "testing" + + "github.com/evcc-io/evcc/util/test" +) + +func TestEEBus(t *testing.T) { + acceptable := []string{ + "eebus not configured", + } + + // Test with explicit grid usage (MGCP) + values := map[string]any{ + "ski": "22dd0b546beaa6a720302119c87fc5e0e7ae2e07", + "ip": "192.0.2.2", + "usage": "grid", + "timeout": "10s", + } + + if _, err := NewFromConfig(context.TODO(), "eebus", values); err != nil && !test.Acceptable(err, acceptable) { + t.Error(err) + } + + // Test without usage parameter (should default to MPC) + valuesNoUsage := map[string]any{ + "ski": "22dd0b546beaa6a720302119c87fc5e0e7ae2e07", + "ip": "192.0.2.2", + "timeout": "10s", + } + + if _, err := NewFromConfig(context.TODO(), "eebus", valuesNoUsage); err != nil && !test.Acceptable(err, acceptable) { + t.Error(err) + } +} diff --git a/meter/template_test.go b/meter/template_test.go index 7664983f2..f1f08be38 100644 --- a/meter/template_test.go +++ b/meter/template_test.go @@ -34,7 +34,8 @@ var acceptable = []string{ "no Speedwire ping response for 127.0.0.1", // SMA "no such network interface", // SMA "missing config values: username, password, key", // E3DC - "missing token", // HomeAssistant + "eebus not configured", // EEBus + "missing token", // HomeAssistant } func TestTemplates(t *testing.T) { diff --git a/server/eebus/eebus.go b/server/eebus/eebus.go index 45af3cd18..83ba71584 100644 --- a/server/eebus/eebus.go +++ b/server/eebus/eebus.go @@ -23,6 +23,7 @@ import ( "github.com/enbility/eebus-go/usecases/cs/lpc" "github.com/enbility/eebus-go/usecases/cs/lpp" "github.com/enbility/eebus-go/usecases/ma/mgcp" + "github.com/enbility/eebus-go/usecases/ma/mpc" shipapi "github.com/enbility/ship-go/api" "github.com/enbility/ship-go/mdns" shiputil "github.com/enbility/ship-go/util" @@ -50,6 +51,7 @@ type UseCasesCS struct { LPC ucapi.CsLPCInterface LPP ucapi.CsLPPInterface MGCP ucapi.MaMGCPInterface + MPC ucapi.MaMPCInterface } type EEBus struct { @@ -154,6 +156,7 @@ func NewServer(other Config) (*EEBus, error) { LPC: lpc.NewLPC(localEntity, c.ucCallback), LPP: lpp.NewLPP(localEntity, c.ucCallback), MGCP: mgcp.NewMGCP(localEntity, c.ucCallback), + MPC: mpc.NewMPC(localEntity, c.ucCallback), } // register use cases @@ -161,7 +164,7 @@ func NewServer(other Config) (*EEBus, error) { c.evseUC.EvseCC, c.evseUC.EvCC, c.evseUC.EvCem, c.evseUC.OpEV, c.evseUC.OscEV, c.evseUC.EvSoc, - c.csUC.LPC, c.csUC.LPP, c.csUC.MGCP, + c.csUC.LPC, c.csUC.LPP, c.csUC.MGCP, c.csUC.MPC, } { c.service.AddUseCase(uc) } diff --git a/templates/definition/meter/eebus-mcp.yaml b/templates/definition/meter/eebus-mcp.yaml new file mode 100644 index 000000000..48f30094f --- /dev/null +++ b/templates/definition/meter/eebus-mcp.yaml @@ -0,0 +1,30 @@ +template: eebus-mcp +products: + - description: + de: "EEBus: Verbraucher" + en: "EEBus: consumer" +group: generic +requirements: + description: + de: EEBus-Verbraucher im Hausnetz mit den Use Cases MPC (Monitoring & Power Consumption) und LPC (Limitation of Power Consumption). Kompatbibel mit steuerbaren Verbrauchseinrichtungen (SteuVE) gemäß §14a EnWG. + en: EEBus consumer in the home network using use cases MPC (Monitoring & Power Consumption) and LPC (Limitation of Power Consumption). + evcc: ["eebus"] +params: + - name: usage + choice: ["charge"] + - name: ski + description: + en: SKI (Subject Key Identifier) + de: SKI (Subject Key Identifier) + - name: host + required: false + help: + en: If not specified, the device will be discovered automatically via mDNS. + de: Falls nicht angegeben, wird das Gerät automatisch über mDNS gefunden. +render: | + type: eebus + ski: {{ .ski }} + {{- if .host }} + ip: {{ .host }} + {{- end }} + usage: {{ .usage }} diff --git a/templates/definition/meter/eebus-mgcp.yaml b/templates/definition/meter/eebus-mgcp.yaml new file mode 100644 index 000000000..f2471b112 --- /dev/null +++ b/templates/definition/meter/eebus-mgcp.yaml @@ -0,0 +1,30 @@ +template: eebus-mgcp +products: + - description: + de: "EEBus: Netzanschlusspunkt" + en: "EEBus: grid connection point" +group: generic +requirements: + description: + de: EEBus-Messstelle am Netzanschlusspunkt mit dem Use Case MGCP (Monitoring of Grid Connection Point). + en: EEBus metering device at the grid connection point using use case MGCP (Monitoring of Grid Connection Point). + evcc: ["eebus"] +params: + - name: usage + choice: ["grid"] + - name: ski + description: + en: SKI (Subject Key Identifier) + de: SKI (Subject Key Identifier) + - name: host + required: false + help: + en: If not specified, the device will be discovered automatically via mDNS. + de: Falls keine Angabe, wird das Gerät automatisch über mDNS gefunden. +render: | + type: eebus + ski: {{ .ski }} + {{- if .host }} + ip: {{ .host }} + {{- end }} + usage: {{ .usage }}