evcc-io/charger/eebus.go
2025-10-31 15:25:53 +00:00

670 lines
18 KiB
Go

package charger
import (
"context"
"errors"
"fmt"
"slices"
"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/enbility/spine-go/model"
"github.com/evcc-io/evcc/api"
"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 {
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
vasVW bool // wether the EVSE supports VW VAS with ISO15118-2
enabled bool
reconnect bool
current float64
*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
VasVW 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, cc.VasVW)
}
//go:generate go tool decorate -f decorateEEBus -b *EEBus -r api.Charger -t "api.Meter,CurrentPower,func() (float64, error)" -t "api.PhaseCurrents,Currents,func() (float64, float64, float64, error)" -t "api.ChargeRater,ChargedEnergy,func() (float64, error)"
// NewEEBus creates EEBus charger
func NewEEBus(ctx context.Context, ski, ip string, hasMeter, hasChargedEnergy, vasVW bool) (api.Charger, error) {
if eebus.Instance == nil {
return nil, errors.New("eebus not configured")
}
c := &EEBus{
log: util.NewLogger("eebus"),
current: 6,
vasVW: vasVW,
cem: eebus.Instance.CustomerEnergyManagement(),
}
c.Connector = eebus.NewConnector()
c.minMaxG = util.Cached(c.minMax, time.Second)
if err := eebus.Instance.RegisterDevice(ski, ip, c); err != nil {
return nil, err
}
if err := c.Wait(ctx); err != nil {
eebus.Instance.UnregisterDevice(ski, c)
return nil, err
}
if hasMeter {
var energyG func() (float64, error)
if hasChargedEnergy {
energyG = c.chargedEnergy
}
return decorateEEBus(c, c.currentPower, c.currents, energyG), nil
}
return c, nil
}
var _ eebus.Device = (*EEBus)(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
}
// if the VW VAS PV mode is active, use PV limits
if c.hasActiveVASVW(evEntity) {
limits, err := c.cem.OscEV.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 {
// check if there is an active limit set
if limit.IsActive && limit.Value >= 1 {
return true, nil
}
}
return false, 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, 1) {
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 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)
}
// if VAS VW is available, limits are completely covered by it
// this way evcc can fully control the charging behavior
if c.writeLoadControlLimitsVASVW(evEntity, limits) {
c.mux.Lock()
defer c.mux.Unlock()
c.limitUpdated = time.Now()
return nil
}
// make sure the recommendations are inactive, otherwise the EV won't go to sleep
// but only if it supports OSCEV and has required data!
if c.cem.OscEV.IsScenarioAvailableAtEntity(evEntity, 1) {
if _, err := c.cem.OscEV.LoadControlLimits(evEntity); err == nil {
if err := c.disableLimits(evEntity, c.cem.OscEV); err != nil {
return err
}
}
}
// set overload protection limits
_, err = c.cem.OpEV.WriteLoadControlLimits(evEntity, limits, nil)
if err == nil {
c.mux.Lock()
defer c.mux.Unlock()
c.limitUpdated = time.Now()
}
return err
}
// returns if the connected EV has an active VW PV mode
// in this mode, the EV does not have an active charging demand
func (c *EEBus) hasActiveVASVW(evEntity spineapi.EntityRemoteInterface) bool {
// EVSE has to support VW VAS
if !c.vasVW {
return false
}
// ISO15118-2 has to be used between EVSE and EV
if comStandard, err := c.cem.EvCC.CommunicationStandard(evEntity); err != nil || comStandard != model.DeviceConfigurationKeyValueStringTypeISO151182ED2 {
return false
}
// SoC has to be available, otherwise it is plain ISO15118-2
// SoC has to be >= 25%, because the Taycan can't be setup with a Min SoC below 25%, oherwise obligations have to be used
if soc, err := c.Soc(); err != nil || soc < 25 {
return false
}
// Optimization of self consumption use case support has to be available
if !c.cem.EvSoc.IsScenarioAvailableAtEntity(evEntity, 1) {
return false
}
// the use case has to be reported as active
// only then the EV has no active charging demand and will charge based on OSCEV recommendations
// this is a workaround for EVSE changing isActive to false, even though they should
// not announce the use case at all in that case
for _, uci := range evEntity.Device().UseCases() {
// check if the referenced entity address is identical to the ev entity address
// the address may not exist, as it only available since SPINE 1.3
if uci.Address != nil &&
evEntity.Address() != nil &&
slices.Compare(uci.Address.Entity, evEntity.Address().Entity) != 0 {
continue
}
for _, uc := range uci.UseCaseSupport {
if uc.UseCaseName != nil && *uc.UseCaseName == model.UseCaseNameTypeOptimizationOfSelfConsumptionDuringEVCharging &&
uc.UseCaseAvailable != nil && *uc.UseCaseAvailable {
return true
}
}
}
return false
}
// provides support for the special VW VAS ISO15118-2 charging behavior if supported
// will return false if it isn't supported or successful
//
// this functionality allows to fully control charging without the EV actually having a
// charging demand by itself
func (c *EEBus) writeLoadControlLimitsVASVW(evEntity spineapi.EntityRemoteInterface, limits []ucapi.LoadLimitsPhase) bool {
if !c.hasActiveVASVW(evEntity) {
return false
}
// check if the EVSE supports optimization of self consumption limits
if !c.cem.OscEV.IsScenarioAvailableAtEntity(evEntity, 1) {
return false
}
// OSCEV requires recommendation limits to be available
if _, err := c.cem.OscEV.LoadControlLimits(evEntity); err != nil {
return false
}
// on OSCEV all limits have to be active except they are set to the default value
minLimits, _, _, err := c.cem.OscEV.CurrentLimits(evEntity)
if err != nil {
return false
}
for index, item := range limits {
// if the limit is equal or bigger than the min allowed, then the recommendation limit is active, otherwise it is not
limits[index].IsActive = false
if index < len(minLimits) {
limits[index].IsActive = item.Value >= minLimits[index]
}
}
// set recommendation limits
if _, err := c.cem.OscEV.WriteLoadControlLimits(evEntity, limits, nil); err != nil {
return false
}
if err := c.disableLimits(evEntity, c.cem.OpEV); err != nil {
return false
}
return true
}
type eebusLimitController interface {
LoadControlLimits(spineapi.EntityRemoteInterface) ([]ucapi.LoadLimitsPhase, error)
WriteLoadControlLimits(spineapi.EntityRemoteInterface, []ucapi.LoadLimitsPhase, func(result model.ResultDataType)) (*model.MsgCounterType, error)
}
// make sure the limits are inactive, otherwise the EV won't go to sleep
func (c *EEBus) disableLimits(evEntity spineapi.EntityRemoteInterface, uc eebusLimitController) error {
limits, err := uc.LoadControlLimits(evEntity)
if err != nil {
return err
}
var writeNeeded bool
for index, item := range limits {
if item.IsActive {
limits[index].IsActive = false
writeNeeded = true
}
}
if writeNeeded {
_, err = uc.WriteLoadControlLimits(evEntity, limits, nil)
}
return 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, 2) {
// 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, 1) {
// 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, 3) {
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, 1) {
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, 1) {
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
}