Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 137 additions & 34 deletions meter/eebus.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -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 limit of 4200W
limit := 4200.0

var value float64
if dim {
value = limit
}

return c.uc.LPC.SetConsumptionLimit(ucapi.LoadLimit{
Value: value,
IsActive: dim,
})
}
37 changes: 37 additions & 0 deletions meter/eebus_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions meter/template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ 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
"eebus not configured", // EEBus
}

func TestTemplates(t *testing.T) {
Expand Down
5 changes: 4 additions & 1 deletion server/eebus/eebus.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -50,6 +51,7 @@ type UseCasesCS struct {
LPC ucapi.CsLPCInterface
LPP ucapi.CsLPPInterface
MGCP ucapi.MaMGCPInterface
MPC ucapi.MaMPCInterface
}

type EEBus struct {
Expand Down Expand Up @@ -154,14 +156,15 @@ 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
for _, uc := range []eebusapi.UseCaseInterface{
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)
}
Expand Down
27 changes: 27 additions & 0 deletions templates/definition/meter/eebus-mcp.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
template: eebus-mcp
products:
- brand: Generic
description:
de: EEBus-Verbraucher (MPC+LPC)
en: EEBus consumer (MPC+LPC)
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: ski
description:
en: SKI (Subject Key Identifier) of the EEBus device
de: SKI (Subject Key Identifier) des EEBus-Geräts
- 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 }}
Loading
Loading