Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OCPP: automatic charger configuration #15243

Merged
merged 12 commits into from
Aug 5, 2024
275 changes: 147 additions & 128 deletions charger/ocpp.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,28 +15,35 @@ import (
"github.com/evcc-io/evcc/core/loadpoint"
"github.com/evcc-io/evcc/util"
"github.com/lorenzodonini/ocpp-go/ocpp1.6/core"
"github.com/lorenzodonini/ocpp-go/ocpp1.6/remotetrigger"
"github.com/lorenzodonini/ocpp-go/ocpp1.6/smartcharging"
"github.com/lorenzodonini/ocpp-go/ocpp1.6/types"
)

// OCPP charger implementation
type OCPP struct {
log *util.Logger
conn *ocpp.Connector
idtag string
phases int
current float64
enabled bool
meterValuesSample string
timeout time.Duration
phaseSwitching bool
remoteStart bool
chargingRateUnit types.ChargingRateUnitType
lp loadpoint.API
bootNotification *core.BootNotificationRequest
}

const defaultIdTag = "evcc" // RemoteStartTransaction only
log *util.Logger
conn *ocpp.Connector
idtag string
phases int
enabled bool
current float64
meterValuesSample string
timeout time.Duration
phaseSwitching bool
remoteStart bool
hasRemoteTriggerFeature bool
chargingRateUnit types.ChargingRateUnitType
chargingProfileId int
stackLevel int
lp loadpoint.API
bootNotification *core.BootNotificationRequest
}

const (
defaultIdTag = "evcc" // RemoteStartTransaction only
desiredMeasurands = "Energy.Active.Import.Register,Power.Active.Import,SoC,Current.Offered,Power.Offered,Current.Import,Voltage"
)

func init() {
registry.Add("ocpp", NewOCPPFromConfig)
Expand All @@ -61,6 +68,7 @@ func NewOCPPFromConfig(other map[string]interface{}) (api.Charger, error) {
}{
Connector: 1,
IdTag: defaultIdTag,
MeterInterval: 10 * time.Second,
ConnectTimeout: ocppConnectTimeout,
Timeout: ocppTimeout,
ChargingRateUnit: "A",
Expand Down Expand Up @@ -167,146 +175,140 @@ func NewOCPP(id string, connector int, idtag string,
case <-cp.HasConnected():
}

// see who's there
if boot {
conn.TriggerMessageRequest(core.BootNotificationFeatureName)
select {
case <-time.After(timeout):
c.log.WARN.Printf("boot notification timeout")
case res := <-cp.BootNotificationRequest():
c.bootNotification = res
}
}

var (
rc = make(chan error, 1)
meterSampleInterval time.Duration
)
rc = make(chan error, 1)

keys := []string{
ocpp.KeyNumberOfConnectors,
ocpp.KeyMeterValuesSampledData,
ocpp.KeyMeterValueSampleInterval,
ocpp.KeyConnectorSwitch3to1PhaseSupported,
ocpp.KeyChargingScheduleAllowedChargingRateUnit,
}
_ = keys
// If a key value is defined as a CSL, it MAY be accompanied with a [KeyName]MaxLength key, indicating the
// max length of the CSL in items. If this key is not set, a safe value of 1 (one) item SHOULD be assumed.
meterValuesSampledDataMaxLength = 1
)

c.chargingRateUnit = types.ChargingRateUnitType(chargingRateUnit)

// noConfig mode disables GetConfiguration
if noConfig {
c.meterValuesSample = meterValues
if meterInterval == 0 {
meterInterval = 10 * time.Second
}
} else {
// fix timing issue in EVBox when switching OCPP protocol version
time.Sleep(time.Second)

err := ocpp.Instance().GetConfiguration(cp.ID(), func(resp *core.GetConfigurationConfirmation, err error) {
if err == nil {
// log unsupported configuration keys
if len(resp.UnknownKey) > 0 {
c.log.ERROR.Printf("unsupported keys: %v", resp.UnknownKey)
// fix timing issue in EVBox when switching OCPP protocol version
time.Sleep(time.Second)

err = ocpp.Instance().GetConfiguration(cp.ID(), func(resp *core.GetConfigurationConfirmation, err error) {
if err == nil {
for _, opt := range resp.ConfigurationKey {
if opt.Value == nil {
continue
}

// sort configuration keys for printing
slices.SortFunc(resp.ConfigurationKey, func(i, j core.ConfigurationKey) int {
return cmp.Compare(i.Key, j.Key)
})
switch opt.Key {
case ocpp.KeyChargeProfileMaxStackLevel:
if val, err := strconv.Atoi(*opt.Value); err == nil {
c.stackLevel = val
}

rw := map[bool]string{false: "r/w", true: "r/o"}
case ocpp.KeyChargingScheduleAllowedChargingRateUnit:
if *opt.Value == "Power" || *opt.Value == "W" { // "W" is not allowed by spec but used by some CPs
c.chargingRateUnit = types.ChargingRateUnitWatts
}

for _, opt := range resp.ConfigurationKey {
if opt.Value == nil {
continue
case ocpp.KeyConnectorSwitch3to1PhaseSupported:
var val bool
if val, err = strconv.ParseBool(*opt.Value); err == nil {
c.phaseSwitching = val
}

c.log.TRACE.Printf("%s (%s): %s", opt.Key, rw[opt.Readonly], *opt.Value)

switch opt.Key {
case ocpp.KeyNumberOfConnectors:
var val int
if val, err = strconv.Atoi(*opt.Value); err == nil && connector > val {
err = fmt.Errorf("connector %d exceeds max available connectors: %d", connector, val)
}

case ocpp.KeyMeterValuesSampledData:
c.meterValuesSample = *opt.Value

case ocpp.KeyMeterValueSampleInterval:
var val int
if val, err = strconv.Atoi(*opt.Value); err == nil {
meterSampleInterval = time.Duration(val) * time.Second
}

case ocpp.KeyConnectorSwitch3to1PhaseSupported:
var val bool
if val, err = strconv.ParseBool(*opt.Value); err == nil {
c.phaseSwitching = val
}

case ocpp.KeyAlfenPlugAndChargeIdentifier:
if c.idtag == defaultIdTag {
c.idtag = *opt.Value
c.log.DEBUG.Printf("overriding default `idTag` with Alfen-specific value: %s", c.idtag)
}

case ocpp.KeyChargingScheduleAllowedChargingRateUnit:
if *opt.Value == "W" || *opt.Value == "Power" {
c.chargingRateUnit = types.ChargingRateUnitWatts
}
case ocpp.KeyMaxChargingProfilesInstalled:
if val, err := strconv.Atoi(*opt.Value); err == nil {
c.chargingProfileId = val
}

if err != nil {
break
case ocpp.KeyMeterValuesSampledDataMaxLength:
if val, err := strconv.Atoi(*opt.Value); err == nil {
meterValuesSampledDataMaxLength = val
}

case ocpp.KeyNumberOfConnectors:
var val int
if val, err = strconv.Atoi(*opt.Value); err == nil && connector > val {
err = fmt.Errorf("connector %d exceeds max available connectors: %d", connector, val)
}

case ocpp.KeySupportedFeatureProfiles:
if !c.hasProperty(*opt.Value, smartcharging.ProfileName) {
err = fmt.Errorf("the mandatory SmartCharging profile is not supported")
}
c.hasRemoteTriggerFeature = c.hasProperty(*opt.Value, remotetrigger.ProfileName)

// vendor-specific keys
case ocpp.KeyAlfenPlugAndChargeIdentifier:
if c.idtag == defaultIdTag {
c.idtag = *opt.Value
c.log.DEBUG.Printf("overriding default `idTag` with Alfen-specific value: %s", c.idtag)
}
}

if err != nil {
break
}
}
}

rc <- err
}, nil)
rc <- err
}, nil)

if err := c.wait(err, rc); err != nil {
return nil, err
}
if err := c.wait(err, rc); err != nil {
return nil, err
}

if meterValues != "" && meterValues != c.meterValuesSample {
if err := c.configure(ocpp.KeyMeterValuesSampledData, meterValues); err != nil {
return nil, err
// see who's there
if c.hasRemoteTriggerFeature {
if err := conn.TriggerMessageRequest(core.BootNotificationFeatureName); err == nil {
select {
case <-time.After(timeout):
c.log.DEBUG.Printf("BootNotification timeout")
case res := <-cp.BootNotificationRequest():
if res != nil {
c.bootNotification = res
}
}
}
}

// configuration activated
c.meterValuesSample = meterValues
// autodetect measurands
if meterValues == "" && meterValuesSampledDataMaxLength > 0 {
sampledMeasurands := c.tryMeasurands(desiredMeasurands, ocpp.KeyMeterValuesSampledData)
if len(sampledMeasurands) > meterValuesSampledDataMaxLength {
meterValues = strings.Join(sampledMeasurands[:meterValuesSampledDataMaxLength], ",")
premultiply marked this conversation as resolved.
Show resolved Hide resolved
}
}

// get initial meter values and configure sample rate
if c.hasMeasurement(types.MeasurandPowerActiveImport) || c.hasMeasurement(types.MeasurandEnergyActiveImportRegister) {
conn.TriggerMessageRequest(core.MeterValuesFeatureName)
// configure measurands
if err := c.configure(ocpp.KeyMeterValuesSampledData, meterValues); err != nil {
return nil, err
}

// wait for meter values
select {
case <-time.After(timeout):
c.log.WARN.Println("meter value timeout")
case <-c.conn.MeterSampled():
}
c.meterValuesSample = meterValues

if meterInterval > 0 && meterInterval != meterSampleInterval {
if err := c.configure(ocpp.KeyMeterValueSampleInterval, strconv.Itoa(int(meterInterval.Seconds()))); err != nil {
return nil, err
// trigger initial meter values
if c.hasRemoteTriggerFeature {
if err := conn.TriggerMessageRequest(core.MeterValuesFeatureName); err == nil {
// wait for meter values
select {
case <-time.After(timeout):
c.log.WARN.Println("meter timeout")
case <-c.conn.MeterSampled():
}
}
}

// HACK: setup watchdog for meter values if not happy with config
if meterInterval > 0 {
c.log.DEBUG.Println("enabling meter watchdog")
go conn.WatchDog(meterInterval)
// configure sample rate
if meterInterval > 0 {
if err := c.configure(ocpp.KeyMeterValueSampleInterval, strconv.Itoa(int(meterInterval.Seconds()))); err != nil {
return nil, err
}
}

// TODO: check for running transaction
if c.hasRemoteTriggerFeature {
go conn.WatchDog(10 * time.Second)
}

// configure ping interval
c.configure(ocpp.KeyWebSocketPingInterval, "30")

return c, conn.Initialized()
}
Expand All @@ -318,7 +320,14 @@ func (c *OCPP) Connector() *ocpp.Connector {

// hasMeasurement checks if meterValuesSample contains given measurement
func (c *OCPP) hasMeasurement(val types.Measurand) bool {
return slices.Contains(strings.Split(c.meterValuesSample, ","), string(val))
return c.hasProperty(c.meterValuesSample, string(val))
}

// hasProperty checks if comma-separated string contains given string ignoring whitespaces
func (c *OCPP) hasProperty(props string, prop string) bool {
return slices.ContainsFunc(strings.Split(props, ","), func(s string) bool {
return prop == strings.TrimSpace(s)
})
}

func (c *OCPP) effectiveIdTag() string {
Expand All @@ -328,6 +337,16 @@ func (c *OCPP) effectiveIdTag() string {
return c.idtag
}

func (c *OCPP) tryMeasurands(measurands string, key string) []string {
var accepted []string
for _, m := range strings.Split(measurands, ",") {
if err := c.configure(key, m); err == nil {
accepted = append(accepted, m)
}
}
return accepted
}

// configure updates CP configuration
func (c *OCPP) configure(key, val string) error {
rc := make(chan error, 1)
Expand Down Expand Up @@ -412,7 +431,7 @@ func (c *OCPP) Enabled() (bool, error) {
}
if c.hasMeasurement(types.MeasurandPowerOffered) {
if v, err := c.getMaxPower(); err == nil {
return v > 0, err
return v > 0, nil
}
}

Expand Down Expand Up @@ -530,8 +549,8 @@ func (c *OCPP) createTxDefaultChargingProfile(current float64) *types.ChargingPr
}

return &types.ChargingProfile{
ChargingProfileId: 1,
StackLevel: 0,
ChargingProfileId: c.chargingProfileId,
StackLevel: c.stackLevel,
ChargingProfilePurpose: types.ChargingProfilePurposeTxDefaultProfile,
ChargingProfileKind: types.ChargingProfileKindAbsolute,
ChargingSchedule: &types.ChargingSchedule{
Expand Down
2 changes: 1 addition & 1 deletion charger/ocpp/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ func (conn *Connector) TriggerMessageRequest(feature remotetrigger.MessageTrigge
// WatchDog triggers meter values messages if older than timeout.
// Must be wrapped in a goroutine.
func (conn *Connector) WatchDog(timeout time.Duration) {
tick := time.NewTicker(timeout)
tick := time.NewTicker(2 * time.Second)
for ; true; <-tick.C {
conn.mu.Lock()
update := conn.txnId != 0 && conn.clock.Since(conn.meterUpdated) > timeout
Expand Down
16 changes: 8 additions & 8 deletions charger/ocpp/const.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,19 @@ package ocpp

const (
// Core profile keys
KeyNumberOfConnectors = "NumberOfConnectors"
KeyMeterValueSampleInterval = "MeterValueSampleInterval"
KeyMeterValuesSampledData = "MeterValuesSampledData"
KeyMeterValuesSampledDataMaxLength = "MeterValuesSampledDataMaxLength"
KeyNumberOfConnectors = "NumberOfConnectors"
KeySupportedFeatureProfiles = "SupportedFeatureProfiles"
KeyWebSocketPingInterval = "WebSocketPingInterval"

// Meter profile keys
KeyMeterValuesSampledData = "MeterValuesSampledData"
KeyMeterValueSampleInterval = "MeterValueSampleInterval"

// Smart Charging profile keys
// SmartCharging profile keys
KeyChargeProfileMaxStackLevel = "ChargeProfileMaxStackLevel"
KeyChargingScheduleAllowedChargingRateUnit = "ChargingScheduleAllowedChargingRateUnit"
KeyChargingScheduleMaxPeriods = "ChargingScheduleMaxPeriods"
KeyConnectorSwitch3to1PhaseSupported = "ConnectorSwitch3to1PhaseSupported"
KeyMaxChargingProfilesInstalled = "MaxChargingProfilesInstalled"

// Alfen specific keys
// Vendor specific keys
KeyAlfenPlugAndChargeIdentifier = "PlugAndChargeIdentifier"
)
Loading