From fc3f563df22426af073c7dd48c74c7f5b243d327 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 27 Aug 2024 22:00:07 +0200 Subject: [PATCH] Move setup from charger to chargepoint (part 1) --- charger/ocpp.go | 327 +++++++++------------------------------ charger/ocpp/cp.go | 13 +- charger/ocpp/cp_setup.go | 196 +++++++++++++++++++++++ charger/ocpp/helper.go | 8 + 4 files changed, 286 insertions(+), 258 deletions(-) create mode 100644 charger/ocpp/cp_setup.go diff --git a/charger/ocpp.go b/charger/ocpp.go index 21c9ca35c5..cfd9d9b07a 100644 --- a/charger/ocpp.go +++ b/charger/ocpp.go @@ -6,8 +6,6 @@ import ( "fmt" "math" "slices" - "strconv" - "strings" "time" "github.com/evcc-io/evcc/api" @@ -15,35 +13,32 @@ 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 - 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 + log *util.Logger + cp *ocpp.CP + 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 = "Power.Active.Import,Energy.Active.Import.Register,Current.Import,Voltage,Current.Offered,Power.Offered,SoC" -) +const defaultIdTag = "evcc" // RemoteStartTransaction only func init() { registry.Add("ocpp", NewOCPPFromConfig) @@ -52,19 +47,21 @@ func init() { // NewOCPPFromConfig creates a OCPP charger from generic config func NewOCPPFromConfig(other map[string]interface{}) (api.Charger, error) { cc := struct { - StationId string - IdTag string - Connector int - MeterInterval time.Duration - MeterValues string - ConnectTimeout time.Duration // Initial Timeout - Timeout time.Duration // Message Timeout - BootNotification *bool // TODO deprecated - GetConfiguration *bool // TODO deprecated - ChargingRateUnit string // TODO deprecated - AutoStart bool // TODO deprecated - NoStop bool // TODO deprecated - RemoteStart bool + StationId string + IdTag string + Connector int + MeterInterval time.Duration + MeterValues string + ConnectTimeout time.Duration // Initial Timeout + Timeout time.Duration // Message Timeout + + BootNotification *bool // TODO deprecated + GetConfiguration *bool // TODO deprecated + ChargingRateUnit types.ChargingRateUnitType // TODO deprecated + AutoStart bool // TODO deprecated + NoStop bool // TODO deprecated + + RemoteStart bool }{ Connector: 1, IdTag: defaultIdTag, @@ -94,33 +91,33 @@ func NewOCPPFromConfig(other map[string]interface{}) (api.Charger, error) { currentsG, voltagesG func() (float64, float64, float64, error) ) - if c.hasMeasurement(types.MeasurandPowerActiveImport) { + if c.cp.HasMeasurement(types.MeasurandPowerActiveImport) { powerG = c.conn.CurrentPower } - if c.hasMeasurement(types.MeasurandEnergyActiveImportRegister) { + if c.cp.HasMeasurement(types.MeasurandEnergyActiveImportRegister) { totalEnergyG = c.conn.TotalEnergy } - if c.hasMeasurement(types.MeasurandCurrentImport) { + if c.cp.HasMeasurement(types.MeasurandCurrentImport) { currentsG = c.conn.Currents } - if c.hasMeasurement(types.MeasurandVoltage) { + if c.cp.HasMeasurement(types.MeasurandVoltage) { voltagesG = c.conn.Voltages } - if c.hasMeasurement(types.MeasurandSoC) { + if c.cp.HasMeasurement(types.MeasurandSoC) { socG = c.conn.Soc } var phasesS func(int) error - if c.phaseSwitching { + if c.cp.PhaseSwitching { phasesS = c.phases1p3p } // var currentG func() (float64, error) - // if c.hasMeasurement(types.MeasurandCurrentOffered) { + // if c.cp.HasMeasurement(types.MeasurandCurrentOffered) { // currentG = c.conn.GetMaxCurrent // } @@ -134,7 +131,7 @@ func NewOCPP(id string, connector int, idtag string, meterValues string, meterInterval time.Duration, boot, noConfig, remoteStart bool, connectTimeout, timeout time.Duration, - chargingRateUnit string, + chargingRateUnit types.ChargingRateUnitType, ) (*OCPP, error) { unit := "ocpp" if id != "" { @@ -146,12 +143,27 @@ func NewOCPP(id string, connector int, idtag string, cp, err := ocpp.Instance().ChargepointByID(id) if err != nil { - cp = ocpp.NewChargePoint(log, id) + cp = ocpp.NewChargePoint(log, id, chargingRateUnit) // should not error if err := ocpp.Instance().Register(id, cp); err != nil { return nil, err } + + // fix timing issue in EVBox when switching OCPP protocol version + time.Sleep(time.Second) + + log.DEBUG.Printf("waiting for chargepoint: %v", connectTimeout) + + select { + case <-time.After(connectTimeout): + return nil, api.ErrTimeout + case <-cp.HasConnected(): + } + + if err := cp.Setup(); err != nil { + return nil, err + } } conn, err := ocpp.NewConnector(log, connector, cp, timeout) @@ -161,185 +173,23 @@ func NewOCPP(id string, connector int, idtag string, c := &OCPP{ log: log, + cp: cp, conn: conn, idtag: idtag, remoteStart: remoteStart, - chargingRateUnit: types.ChargingRateUnitType(chargingRateUnit), - hasRemoteTriggerFeature: true, // assume remote trigger feature is available - timeout: timeout, - } - - c.log.DEBUG.Printf("waiting for chargepoint: %v", connectTimeout) - - select { - case <-time.After(connectTimeout): - return nil, api.ErrTimeout - case <-cp.HasConnected(): - } - - // fix timing issue in EVBox when switching OCPP protocol version - time.Sleep(time.Second) - - if err := ocpp.Instance().ChangeAvailabilityRequest(cp.ID(), 0, core.AvailabilityTypeOperative); err != nil { - c.log.DEBUG.Printf("failed configuring availability: %v", err) - } - - var meterValuesSampledData string - meterValuesSampledDataMaxLength := len(strings.Split(desiredMeasurands, ",")) - - rc := make(chan error, 1) - - // CP - 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 - } - - switch opt.Key { - case ocpp.KeyChargeProfileMaxStackLevel: - if val, err := strconv.Atoi(*opt.Value); err == nil { - c.stackLevel = val - } - - 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 - } - - case ocpp.KeyConnectorSwitch3to1PhaseSupported: - var val bool - if val, err = strconv.ParseBool(*opt.Value); err == nil { - c.phaseSwitching = val - } - - case ocpp.KeyMaxChargingProfilesInstalled: - if val, err := strconv.Atoi(*opt.Value); err == nil { - c.chargingProfileId = val - } - - case ocpp.KeyMeterValuesSampledData: - if opt.Readonly { - meterValuesSampledDataMaxLength = 0 - } - meterValuesSampledData = *opt.Value - - 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) { - c.log.WARN.Printf("the required SmartCharging feature profile is not indicated as supported") - } - // correct the availability assumption of RemoteTrigger only in case of a valid looking FeatureProfile list - if c.hasProperty(*opt.Value, core.ProfileName) { - 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) - } - - case ocpp.KeyEvBoxSupportedMeasurands: - if meterValues == "" { - meterValues = *opt.Value - } - } - - if err != nil { - break - } - } - } - - rc <- err - }, nil) - - if err := c.wait(err, rc); err != nil { - return nil, err - } - - // see who's there - if c.hasRemoteTriggerFeature { - // CP - if err := ocpp.Instance().TriggerMessageRequest(cp.ID(), core.BootNotificationFeatureName); err != nil { - c.log.DEBUG.Printf("failed triggering BootNotification: %v", err) - } - - select { - case <-time.After(timeout): - c.log.DEBUG.Printf("BootNotification timeout") - case res := <-cp.BootNotificationRequest(): - if res != nil { - c.bootNotification = res - } - } - } - - // autodetect measurands - if meterValues == "" && meterValuesSampledDataMaxLength > 0 { - sampledMeasurands := c.tryMeasurands(desiredMeasurands, ocpp.KeyMeterValuesSampledData) - meterValues = strings.Join(sampledMeasurands[:min(len(sampledMeasurands), meterValuesSampledDataMaxLength)], ",") - } - - // configure measurands - if meterValues != "" { - // CP - if err := c.configure(ocpp.KeyMeterValuesSampledData, meterValues); err == nil { - meterValuesSampledData = meterValues - } - } - - c.meterValuesSample = meterValuesSampledData - - // trigger initial meter values - if c.hasRemoteTriggerFeature { - // CP - 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(): - } - } - } - - // configure sample rate - if meterInterval > 0 { - // CP - if err := c.configure(ocpp.KeyMeterValueSampleInterval, strconv.Itoa(int(meterInterval.Seconds()))); err != nil { - c.log.WARN.Printf("failed configuring MeterValueSampleInterval: %v", err) - } + // chargingRateUnit: types.ChargingRateUnitType(chargingRateUnit), + // hasRemoteTriggerFeature: true, // assume remote trigger feature is available + timeout: timeout, } - if c.hasRemoteTriggerFeature { - // CP - go conn.WatchDog(10 * time.Second) - } - - // configure ping interval - // CP - c.configure(ocpp.KeyWebSocketPingInterval, "30") - // CONN - if c.hasRemoteTriggerFeature { + if cp.HasRemoteTriggerFeature { if err := conn.TriggerMessageRequest(core.StatusNotificationFeatureName); err != nil { c.log.DEBUG.Printf("failed triggering StatusNotification: %v", err) } + + go conn.WatchDog(10 * time.Second) } return c, conn.Initialized() @@ -350,18 +200,6 @@ func (c *OCPP) Connector() *ocpp.Connector { return c.conn } -// hasMeasurement checks if meterValuesSample contains given measurement -func (c *OCPP) hasMeasurement(val types.Measurand) bool { - 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 strings.HasPrefix(strings.ReplaceAll(s, " ", ""), prop) - }) -} - func (c *OCPP) effectiveIdTag() string { if idtag := c.conn.IdTag(); idtag != "" { return idtag @@ -369,31 +207,6 @@ 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) - - err := ocpp.Instance().ChangeConfiguration(c.conn.ChargePoint().ID(), func(resp *core.ChangeConfigurationConfirmation, err error) { - if err == nil && resp != nil && resp.Status != core.ConfigurationStatusAccepted { - rc <- fmt.Errorf("ChangeConfiguration failed: %s", resp.Status) - } - - rc <- err - }, key, val) - - return c.wait(err, rc) -} - // wait waits for a CP roundtrip with timeout func (c *OCPP) wait(err error, rc chan error) error { return ocpp.Wait(err, rc, c.timeout) @@ -477,12 +290,12 @@ func (c *OCPP) Enabled() (bool, error) { } // fallback to the "offered" measurands - if c.hasMeasurement(types.MeasurandCurrentOffered) { + if c.cp.HasMeasurement(types.MeasurandCurrentOffered) { if v, err := c.conn.GetMaxCurrent(); err == nil { return v > 0, nil } } - if c.hasMeasurement(types.MeasurandPowerOffered) { + if c.cp.HasMeasurement(types.MeasurandPowerOffered) { if v, err := c.conn.GetMaxPower(); err == nil { return v > 0, nil } @@ -585,7 +398,7 @@ func (c *OCPP) getScheduleLimit() (float64, error) { func (c *OCPP) createTxDefaultChargingProfile(current float64) *types.ChargingProfile { phases := c.phases period := types.NewChargingSchedulePeriod(0, current) - if c.chargingRateUnit == types.ChargingRateUnitWatts { + if c.cp.ChargingRateUnit == types.ChargingRateUnitWatts { // get (expectedly) active phases from loadpoint if c.lp != nil { phases = c.lp.GetPhases() @@ -602,13 +415,13 @@ func (c *OCPP) createTxDefaultChargingProfile(current float64) *types.ChargingPr } return &types.ChargingProfile{ - ChargingProfileId: c.chargingProfileId, - StackLevel: c.stackLevel, + ChargingProfileId: c.cp.ChargingProfileId, + StackLevel: c.cp.StackLevel, ChargingProfilePurpose: types.ChargingProfilePurposeTxDefaultProfile, ChargingProfileKind: types.ChargingProfileKindAbsolute, ChargingSchedule: &types.ChargingSchedule{ StartSchedule: types.Now(), - ChargingRateUnit: c.chargingRateUnit, + ChargingRateUnit: c.cp.ChargingRateUnit, ChargingSchedulePeriod: []types.ChargingSchedulePeriod{period}, }, } diff --git a/charger/ocpp/cp.go b/charger/ocpp/cp.go index 05621d58bf..f9ad29663d 100644 --- a/charger/ocpp/cp.go +++ b/charger/ocpp/cp.go @@ -6,6 +6,7 @@ import ( "github.com/evcc-io/evcc/util" "github.com/lorenzodonini/ocpp-go/ocpp1.6/core" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/types" ) // TODO support multiple connectors @@ -24,10 +25,17 @@ type CP struct { connected bool connectC chan struct{} + // configuration properties + PhaseSwitching bool + HasRemoteTriggerFeature bool + ChargingRateUnit types.ChargingRateUnitType + ChargingProfileId int + StackLevel int + connectors map[int]*Connector } -func NewChargePoint(log *util.Logger, id string) *CP { +func NewChargePoint(log *util.Logger, id string, chargingRateUnit types.ChargingRateUnitType) *CP { return &CP{ log: log, id: id, @@ -36,6 +44,9 @@ func NewChargePoint(log *util.Logger, id string) *CP { connectors: make(map[int]*Connector), bootNotificationRequestC: make(chan *core.BootNotificationRequest, 1), + + ChargingRateUnit: chargingRateUnit, + HasRemoteTriggerFeature: true, // assume remote trigger feature is available } } diff --git a/charger/ocpp/cp_setup.go b/charger/ocpp/cp_setup.go new file mode 100644 index 0000000000..753a0cb8d4 --- /dev/null +++ b/charger/ocpp/cp_setup.go @@ -0,0 +1,196 @@ +package ocpp + +import ( + "fmt" + "strconv" + "strings" + "time" + + "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" +) + +const desiredMeasurands = "Power.Active.Import,Energy.Active.Import.Register,Current.Import,Voltage,Current.Offered,Power.Offered,SoC" + +func (cp *CP) Setup() error { + if err := Instance().ChangeAvailabilityRequest(cp.ID(), 0, core.AvailabilityTypeOperative); err != nil { + cp.log.DEBUG.Printf("failed configuring availability: %v", err) + } + + var meterValuesSampledData string + meterValuesSampledDataMaxLength := len(strings.Split(desiredMeasurands, ",")) + + rc := make(chan error, 1) + + // CP + err := Instance().GetConfiguration(cp.ID(), func(resp *core.GetConfigurationConfirmation, err error) { + if err == nil { + for _, opt := range resp.ConfigurationKey { + if opt.Value == nil { + continue + } + + switch opt.Key { + case KeyChargeProfileMaxStackLevel: + if val, err := strconv.Atoi(*opt.Value); err == nil { + cp.StackLevel = val + } + + case KeyChargingScheduleAllowedChargingRateUnit: + if *opt.Value == "Power" || *opt.Value == "W" { // "W" is not allowed by spec but used by some CPs + cp.ChargingRateUnit = types.ChargingRateUnitWatts + } + + case KeyConnectorSwitch3to1PhaseSupported: + var val bool + if val, err = strconv.ParseBool(*opt.Value); err == nil { + cp.PhaseSwitching = val + } + + case KeyMaxChargingProfilesInstalled: + if val, err := strconv.Atoi(*opt.Value); err == nil { + cp.ChargingProfileId = val + } + + case KeyMeterValuesSampledData: + if opt.Readonly { + meterValuesSampledDataMaxLength = 0 + } + meterValuesSampledData = *opt.Value + + case KeyMeterValuesSampledDataMaxLength: + if val, err := strconv.Atoi(*opt.Value); err == nil { + meterValuesSampledDataMaxLength = val + } + + case 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 KeySupportedFeatureProfiles: + if !hasProperty(*opt.Value, smartcharging.ProfileName) { + cp.log.WARN.Printf("the required SmartCharging feature profile is not indicated as supported") + } + // correct the availability assumption of RemoteTrigger only in case of a valid looking FeatureProfile list + if hasProperty(*opt.Value, core.ProfileName) { + cp.HasRemoteTriggerFeature = hasProperty(*opt.Value, remotetrigger.ProfileName) + } + + // vendor-specific keys + case KeyAlfenPlugAndChargeIdentifier: + if cp.idtag == defaultIdTag { + cp.idtag = *opt.Value + cp.log.DEBUG.Printf("overriding default `idTag` with Alfen-specific value: %s", cp.idtag) + } + + case KeyEvBoxSupportedMeasurands: + if meterValues == "" { + meterValues = *opt.Value + } + } + + if err != nil { + break + } + } + } + + rc <- err + }, nil) + + if err := cp.wait(err, rc); err != nil { + return nil, err + } + + // see who's there + if cp.HasRemoteTriggerFeature { + // CP + if err := Instance().TriggerMessageRequest(cp.ID(), core.BootNotificationFeatureName); err != nil { + cp.log.DEBUG.Printf("failed triggering BootNotification: %v", err) + } + + select { + case <-time.After(timeout): + cp.log.DEBUG.Printf("BootNotification timeout") + case res := <-cp.BootNotificationRequest(): + if res != nil { + cp.bootNotification = res + } + } + } + + // autodetect measurands + if meterValues == "" && meterValuesSampledDataMaxLength > 0 { + sampledMeasurands := cp.tryMeasurands(desiredMeasurands, KeyMeterValuesSampledData) + meterValues = strings.Join(sampledMeasurands[:min(len(sampledMeasurands), meterValuesSampledDataMaxLength)], ",") + } + + // configure measurands + if meterValues != "" { + // CP + if err := cp.configure(KeyMeterValuesSampledData, meterValues); err == nil { + meterValuesSampledData = meterValues + } + } + + cp.meterValuesSample = meterValuesSampledData + + // trigger initial meter values + if cp.HasRemoteTriggerFeature { + // CP + if err := conn.TriggerMessageRequest(core.MeterValuesFeatureName); err == nil { + // wait for meter values + select { + case <-time.After(timeout): + cp.log.WARN.Println("meter timeout") + case <-cp.conn.MeterSampled(): + } + } + } + + // configure sample rate + if meterInterval > 0 { + // CP + if err := cp.configure(KeyMeterValueSampleInterval, strconv.Itoa(int(meterInterval.Seconds()))); err != nil { + cp.log.WARN.Printf("failed configuring MeterValueSampleInterval: %v", err) + } + } + + // configure ping interval + // CP + cp.configure(KeyWebSocketPingInterval, "30") +} + +// HasMeasurement checks if meterValuesSample contains given measurement +func (cp *CP) HasMeasurement(val types.Measurand) bool { + return hasProperty(cp.meterValuesSample, string(val)) +} + +func (cp *CP) tryMeasurands(measurands string, key string) []string { + var accepted []string + for _, m := range strings.Split(measurands, ",") { + if err := cp.configure(key, m); err == nil { + accepted = append(accepted, m) + } + } + return accepted +} + +// configure updates CP configuration +func (cp *CP) configure(key, val string) error { + rc := make(chan error, 1) + + err := Instance().ChangeConfiguration(cp.id, func(resp *core.ChangeConfigurationConfirmation, err error) { + if err == nil && resp != nil && resp.Status != core.ConfigurationStatusAccepted { + rc <- fmt.Errorf("ChangeConfiguration failed: %s", resp.Status) + } + + rc <- err + }, key, val) + + return c.wait(err, rc) +} diff --git a/charger/ocpp/helper.go b/charger/ocpp/helper.go index 12dce8d5e8..285bf90feb 100644 --- a/charger/ocpp/helper.go +++ b/charger/ocpp/helper.go @@ -2,6 +2,7 @@ package ocpp import ( "slices" + "strings" "time" "github.com/evcc-io/evcc/api" @@ -33,3 +34,10 @@ func sortByAge(values []types.MeterValue) []types.MeterValue { return at.Compare(bt) }) } + +// hasProperty checks if comma-separated string contains given string ignoring whitespaces +func hasProperty(props string, prop string) bool { + return slices.ContainsFunc(strings.Split(props, ","), func(s string) bool { + return strings.HasPrefix(strings.ReplaceAll(s, " ", ""), prop) + }) +}