evcc-io/charger/eebus.go
2026-06-25 17:26:08 +02:00

586 lines
15 KiB
Go

package charger
import (
"context"
"fmt"
"sync"
"time"
eebusapi "github.com/enbility/eebus-go/api"
ucapi "github.com/enbility/eebus-go/usecases/api"
"github.com/enbility/eebus-go/usecases/cem/evcc"
"github.com/enbility/eebus-go/usecases/cem/evcem"
spineapi "github.com/enbility/spine-go/api"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/api/implement"
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/server/eebus"
"github.com/evcc-io/evcc/util"
"github.com/samber/lo"
)
const (
idleFactor = 0.6
voltage float64 = 230
)
type minMax struct {
min, max float64
}
type EEBus struct {
implement.Caps
cem *eebus.CustomerEnergyManagement
ev spineapi.EntityRemoteInterface
mux sync.RWMutex
log *util.Logger
lp loadpoint.API
minMaxG func() (minMax, error)
limitUpdated time.Time // time of last limit change
enabled bool
reconnect bool
current float64
connector *eebus.Connector
}
func init() {
registry.AddCtx("eebus", NewEEBusFromConfig)
}
// NewEEBusFromConfig creates an EEBus charger from generic config
func NewEEBusFromConfig(ctx context.Context, other map[string]any) (api.Charger, error) {
var cc struct {
Ski string
Ip string
Meter bool
ChargedEnergy *bool
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
// default true
hasChargedEnergy := cc.ChargedEnergy == nil || *cc.ChargedEnergy
return NewEEBus(ctx, cc.Ski, cc.Ip, cc.Meter, hasChargedEnergy)
}
// newEEBus creates and initializes a raw *EEBus charger.
// It registers the device with the EEBus instance and waits for the connection.
func newEEBus(ctx context.Context, ski, ip string) (*EEBus, error) {
inst, err := eebus.Instance()
if err != nil {
return nil, err
}
c := &EEBus{
Caps: implement.New(),
log: util.NewLogger("eebus"),
current: 6,
cem: inst.CustomerEnergyManagement(),
}
c.connector = eebus.NewConnector()
c.minMaxG = util.Cached(c.minMax, time.Second)
if err := inst.RegisterDevice(ski, ip, c); err != nil {
return nil, err
}
if err := c.connector.Wait(ctx); err != nil {
inst.UnregisterDevice(ski, c)
return nil, err
}
// unregister device when context is cancelled (e.g. UI config validation)
go func() {
<-ctx.Done()
inst.UnregisterDevice(ski, c)
}()
return c, nil
}
// NewEEBus creates EEBus charger
func NewEEBus(ctx context.Context, ski, ip string, hasMeter, hasChargedEnergy bool) (api.Charger, error) {
c, err := newEEBus(ctx, ski, ip)
if err != nil {
return nil, err
}
if hasMeter {
implement.Has(c, implement.Meter(c.currentPower))
implement.Has(c, implement.PhaseCurrents(c.currents))
if hasChargedEnergy {
implement.Has(c, implement.ChargeRater(c.chargedEnergy))
}
}
return c, nil
}
var _ eebus.Device = (*EEBus)(nil)
// Connect implements the eebus.Device interface.
// On SHIP/SPINE disconnect we drop the cached EV entity reference. EvDisconnected
// only fires on a SPINE EntityChange/Remove, not on SHIP-level disconnect, so
// without this we could keep querying an orphan entity until the next reconnect
// re-fires EvConnected.
func (c *EEBus) Connect(connected bool) {
c.connector.Connect(connected)
if connected {
return
}
c.mux.Lock()
defer c.mux.Unlock()
c.ev = nil
}
// UseCaseEvent implements the eebus.Device interface
func (c *EEBus) UseCaseEvent(device spineapi.DeviceRemoteInterface, entity spineapi.EntityRemoteInterface, event eebusapi.EventType) {
c.mux.Lock()
defer c.mux.Unlock()
// EV
switch event {
case evcc.EvConnected:
c.ev = entity
c.reconnect = true
case evcc.EvDisconnected:
c.ev = nil
case evcem.DataUpdateCurrentPerPhase:
// acknowledge limit change
c.limitUpdated = time.Time{}
}
}
func (c *EEBus) isEvConnected() (spineapi.EntityRemoteInterface, bool) {
c.mux.RLock()
defer c.mux.RUnlock()
return c.ev, c.ev != nil && c.cem.EvCC.EVConnected(c.ev)
}
// we assume that if any phase current value is > idleFactor * min Current, then charging is active and enabled is true
func (c *EEBus) isCharging(evEntity spineapi.EntityRemoteInterface) bool {
// check if an external physical meter is assigned
// we only want this for configured meters and not for internal meters!
// right now it works as expected
var minPower float64
if c.lp != nil {
minPower = c.lp.EffectiveMinPower()
if c.lp.HasChargeMeter() {
return c.lp.GetChargePower() > minPower*idleFactor
}
}
// The above doesn't (yet) work for built in meters, so check the EEBUS measurements also
// use power data if available, otherwise the method will calculate the power from the current data
power, err := c.currentPower()
if err != nil {
return false
}
if c.lp == nil {
limitsMin, _, _, err := c.cem.OpEV.CurrentLimits(evEntity)
if err != nil || len(limitsMin) == 0 {
// sometimes a min limit is not provided by the EVSE, and we can't take it from the loadpoint
return false
}
minPower = limitsMin[0] * voltage
}
return power > minPower*idleFactor
}
// Status implements the api.Charger interface
func (c *EEBus) Status() (res api.ChargeStatus, err error) {
evEntity, ok := c.isEvConnected()
if !ok {
return api.StatusA, nil
}
// re-set current limit after reconnect
defer func() {
if err != nil {
return
}
c.mux.Lock()
if !c.reconnect && (res == api.StatusB || res == api.StatusC) {
c.mux.Unlock()
return
}
c.reconnect = false
c.mux.Unlock()
var current float64
if c.enabled {
current = c.current
}
err = c.writeCurrentLimitData(evEntity, current)
}()
currentState, err := c.cem.EvCC.ChargeState(evEntity)
if err != nil {
return api.StatusA, nil
}
switch currentState {
case ucapi.EVChargeStateTypeUnknown, ucapi.EVChargeStateTypeUnplugged: // Unplugged
return api.StatusA, nil
case ucapi.EVChargeStateTypeFinished, ucapi.EVChargeStateTypePaused: // Finished, Paused
return api.StatusB, nil
case ucapi.EVChargeStateTypeActive: // Active
if c.isCharging(evEntity) {
return api.StatusC, nil
}
return api.StatusB, nil
default:
return api.StatusNone, fmt.Errorf("invalid status: %s", currentState)
}
}
// Enabled implements the api.Charger interface
// should return true if the charger allows the EV to draw power
func (c *EEBus) Enabled() (bool, error) {
// when unplugged there is no overload limit data available
evEntity, ok := c.isEvConnected()
if !ok {
return c.enabled, nil
}
limits, err := c.cem.OpEV.LoadControlLimits(evEntity)
if err != nil {
// there are no limits available, e.g. because the data was not received yet
return c.enabled, nil
}
for _, limit := range limits {
// for IEC61851 the pause limit is 0A, for ISO15118-2 it is 0.1A
// instead of checking for the actual data, hardcode this, so we might run into less
// timing issues as the data might not be received yet
// if the limit is not active, then the maximum possible current is permitted
if limit.IsActive && limit.Value >= 1 || !limit.IsActive {
return true, nil
}
}
return false, nil
}
// Enable implements the api.Charger interface
func (c *EEBus) Enable(enable bool) error {
// if the ev is unplugged or the state is unknown, there is nothing to be done
evEntity, ok := c.isEvConnected()
if !ok {
c.enabled = enable
return nil
}
// if we disable charging with a potential but not yet known communication standard ISO15118
// this would set allowed A value to be 0. And this would trigger ISO connections to switch to IEC!
if !enable {
comStandard, err := c.cem.EvCC.CommunicationStandard(evEntity)
if err != nil || comStandard == evcc.EVCCCommunicationStandardUnknown {
return api.ErrMustRetry
}
}
var current float64
if enable {
current = c.current
}
err := c.writeCurrentLimitData(evEntity, current)
if err == nil {
c.enabled = enable
}
return err
}
// send current charging power limits to the EV
func (c *EEBus) writeCurrentLimitData(evEntity spineapi.EntityRemoteInterface, current float64) error {
// check if the EVSE supports overload protection limits
if !c.cem.OpEV.IsScenarioAvailableAtEntity(evEntity, eebus.OPEVObligationLimit) {
return api.ErrNotAvailable
}
_, maxLimits, _, err := c.cem.OpEV.CurrentLimits(evEntity)
if err != nil {
c.log.DEBUG.Println("no limits from the EVSE are provided:", err)
}
// setup the obligation limit data structure
var limits []ucapi.LoadLimitsPhase
for phase := range len(ucapi.PhaseNameMapping) {
limit := ucapi.LoadLimitsPhase{
Phase: ucapi.PhaseNameMapping[phase],
IsActive: true,
Value: current,
}
// if the limit equals to the max allowed, then the obligation limit is actually inactive
if phase < len(maxLimits) && current >= maxLimits[phase] {
limit.IsActive = false
}
limits = append(limits, limit)
}
// always set overload protection limits (obligation)
if _, err := c.cem.OpEV.WriteLoadControlLimits(evEntity, limits, nil); err != nil {
return err
}
// additionally set self-consumption recommendation limits if available
c.writeOscevLimits(evEntity, current)
c.mux.Lock()
defer c.mux.Unlock()
c.limitUpdated = time.Now()
return nil
}
// writeOscevLimits writes OSCEV recommendation limits if the use case is available.
// An active recommendation triggers the EV to charge with surplus energy.
// An inactive recommendation is equivalent to no recommendation existing.
func (c *EEBus) writeOscevLimits(evEntity spineapi.EntityRemoteInterface, current float64) {
if !c.cem.OscEV.IsScenarioAvailableAtEntity(evEntity, eebus.OSCEVRecommendationLimit) {
return
}
// OSCEV requires recommendation limits to be available
if _, err := c.cem.OscEV.LoadControlLimits(evEntity); err != nil {
return
}
minLimits, _, _, err := c.cem.OscEV.CurrentLimits(evEntity)
if err != nil {
return
}
var limits []ucapi.LoadLimitsPhase
for phase := range len(ucapi.PhaseNameMapping) {
limit := ucapi.LoadLimitsPhase{
Phase: ucapi.PhaseNameMapping[phase],
IsActive: false,
Value: current,
}
// below min charging current there is nothing to recommend
// in contrast to OPEV the max value has to be active to trigger the recommendation to have any effect
if phase < len(minLimits) {
limit.IsActive = current >= minLimits[phase]
}
limits = append(limits, limit)
}
if _, err := c.cem.OscEV.WriteLoadControlLimits(evEntity, limits, nil); err != nil {
c.log.DEBUG.Println("failed to write OSCEV limits:", err)
}
}
// MaxCurrent implements the api.Charger interface
func (c *EEBus) MaxCurrent(current int64) error {
return c.MaxCurrentMillis(float64(current))
}
var _ api.ChargerEx = (*EEBus)(nil)
// MaxCurrentMillis implements the api.ChargerEx interface
func (c *EEBus) MaxCurrentMillis(current float64) error {
evEntity, ok := c.isEvConnected()
if !ok {
c.current = current
return nil
}
err := c.writeCurrentLimitData(evEntity, current)
if err == nil {
c.current = current
}
return nil
}
// CurrentPower implements the api.Meter interface
func (c *EEBus) currentPower() (float64, error) {
evEntity, ok := c.isEvConnected()
if !ok {
return 0, nil
}
// does the EVSE provide power data?
var powers []float64
if c.cem.EvCem.IsScenarioAvailableAtEntity(evEntity, eebus.EVCEMPowerTotal) {
// is power data available for real? Elli Gen1 says it supports it, but doesn't provide any data
if powerData, err := c.cem.EvCem.PowerPerPhase(evEntity); err == nil {
powers = powerData
}
}
// if no power data is available, and currents are reported to be supported, use currents
if len(powers) == 0 && c.cem.EvCem.IsScenarioAvailableAtEntity(evEntity, eebus.EVCEMPowerPerPhase) {
// no power provided, calculate from current
if currents, err := c.cem.EvCem.CurrentPerPhase(evEntity); err == nil {
for _, current := range currents {
powers = append(powers, current*voltage)
}
}
}
// if still no power data is available, return an error
if len(powers) == 0 {
return 0, api.ErrNotAvailable
}
return lo.Sum(powers), nil
}
// ChargedEnergy implements the api.ChargeRater interface
func (c *EEBus) chargedEnergy() (float64, error) {
evEntity, ok := c.isEvConnected()
if !ok {
return 0, nil
}
if !c.cem.EvCem.IsScenarioAvailableAtEntity(evEntity, eebus.EVCEMEnergy) {
return 0, api.ErrNotAvailable
}
energy, err := c.cem.EvCem.EnergyCharged(evEntity)
if err != nil {
return 0, api.ErrNotAvailable
}
return energy / 1e3, nil
}
// Currents implements the api.PhaseCurrents interface
func (c *EEBus) currents() (float64, float64, float64, error) {
evEntity, ok := c.isEvConnected()
if !ok {
return 0, 0, 0, nil
}
// check if the EVSE supports currents
if !c.cem.EvCem.IsScenarioAvailableAtEntity(evEntity, eebus.EVCEMPowerPerPhase) {
return 0, 0, 0, api.ErrNotAvailable
}
c.mux.Lock()
ts := c.limitUpdated
c.mux.Unlock()
// if the last limit update is not zero (meaning no measurement was provided yet)
// only consider this an error, if the last limit update is older than 15 seconds
// this covers the case where this function may be called shortly after setting a limit
// but too short for a measurement can even be received
if d := time.Since(ts); d > 15*time.Second && !ts.IsZero() {
return 0, 0, 0, api.ErrNotAvailable
}
res, err := c.cem.EvCem.CurrentPerPhase(evEntity)
if err != nil {
return 0, 0, 0, eebus.WrapError(err)
}
// fill phases
for len(res) < 3 {
res = append(res, 0)
}
return res[0], res[1], res[2], nil
}
var _ api.Identifier = (*EEBus)(nil)
// Identify implements the api.Identifier interface
func (c *EEBus) Identify() (string, error) {
evEntity, ok := c.isEvConnected()
if !ok {
return "", nil
}
if identification, err := c.cem.EvCC.Identifications(evEntity); err == nil && len(identification) > 0 {
// return the first identification for now
// later this could be multiple, e.g. MAC Address and PCID
return identification[0].Value, nil
}
return "", nil
}
var _ api.Battery = (*EEBus)(nil)
// Soc implements the api.Battery interface
func (c *EEBus) Soc() (float64, error) {
evEntity, ok := c.isEvConnected()
if !ok {
return 0, api.ErrNotAvailable
}
if !c.cem.EvSoc.IsScenarioAvailableAtEntity(evEntity, eebus.EVSOCStateOfCharge) {
return 0, api.ErrNotAvailable
}
soc, err := c.cem.EvSoc.StateOfCharge(evEntity)
if err != nil {
return 0, api.ErrNotAvailable
}
return soc, nil
}
var _ api.CurrentLimiter = (*EEBus)(nil)
func (c *EEBus) minMax() (minMax, error) {
var zero minMax
evEntity, ok := c.isEvConnected()
if !ok {
return zero, nil
}
minLimits, maxLimits, _, err := c.cem.OpEV.CurrentLimits(evEntity)
if err != nil {
return zero, eebus.WrapError(err)
}
if len(minLimits) == 0 || len(maxLimits) == 0 {
return zero, api.ErrNotAvailable
}
return minMax{minLimits[0], maxLimits[0]}, nil
}
func (c *EEBus) GetMinMaxCurrent() (float64, float64, error) {
minMax, err := c.minMaxG()
return minMax.min, minMax.max, err
}
var _ loadpoint.Controller = (*EEBus)(nil)
// LoadpointControl implements loadpoint.Controller
func (c *EEBus) LoadpointControl(lp loadpoint.API) {
c.lp = lp
}