evcc-io/charger/ocpp.go

504 lines
13 KiB
Go

package charger
import (
"cmp"
"errors"
"fmt"
"math"
"slices"
"strconv"
"strings"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/charger/ocpp"
"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/smartcharging"
"github.com/lorenzodonini/ocpp-go/ocpp1.6/types"
)
// OCPP charger implementation
type OCPP struct {
log *util.Logger
conn *ocpp.Connector
idtag string
enabled bool
phases int
current float64
meterValuesSample string
timeout time.Duration
phaseSwitching bool
chargingRateUnit types.ChargingRateUnitType
lp loadpoint.API
}
const defaultIdTag = "evcc"
func init() {
registry.Add("ocpp", NewOCPPFromConfig)
}
// 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
Timeout time.Duration
BootNotification *bool
GetConfiguration *bool
ChargingRateUnit string
}{
Connector: 1,
IdTag: defaultIdTag,
ConnectTimeout: ocppConnectTimeout,
Timeout: ocppTimeout,
ChargingRateUnit: "A",
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
boot := cc.BootNotification != nil && *cc.BootNotification
noConfig := cc.GetConfiguration != nil && !*cc.GetConfiguration
c, err := NewOCPP(cc.StationId, cc.Connector, cc.IdTag,
cc.MeterValues, cc.MeterInterval,
boot, noConfig,
cc.ConnectTimeout, cc.Timeout, cc.ChargingRateUnit)
if err != nil {
return c, err
}
var powerG func() (float64, error)
if c.hasMeasurement(types.MeasurandPowerActiveImport) {
powerG = c.currentPower
}
var totalEnergyG func() (float64, error)
if c.hasMeasurement(types.MeasurandEnergyActiveImportRegister) {
totalEnergyG = c.totalEnergy
}
var currentsG func() (float64, float64, float64, error)
if c.hasMeasurement(types.MeasurandCurrentImport + ".L3") {
currentsG = c.currents
}
var phasesS func(int) error
if c.phaseSwitching {
phasesS = c.phases1p3p
}
return decorateOCPP(c, powerG, totalEnergyG, currentsG, phasesS), nil
}
//go:generate go run ../cmd/tools/decorate.go -f decorateOCPP -b *OCPP -r api.Charger -t "api.Meter,CurrentPower,func() (float64, error)" -t "api.MeterEnergy,TotalEnergy,func() (float64, error)" -t "api.PhaseCurrents,Currents,func() (float64, float64, float64, error)" -t "api.PhaseSwitcher,Phases1p3p,func(int) error"
// NewOCPP creates OCPP charger
func NewOCPP(id string, connector int, idtag string,
meterValues string, meterInterval time.Duration,
boot, noConfig bool,
connectTimeout, timeout time.Duration,
chargingRateUnit string,
) (*OCPP, error) {
unit := "ocpp"
if id != "" {
unit = id
}
unit = fmt.Sprintf("%s-%d", unit, connector)
log := util.NewLogger(unit)
cp, err := ocpp.Instance().ChargepointByID(id)
if err != nil {
cp = ocpp.NewChargePoint(log, id)
// should not error
if err := ocpp.Instance().Register(id, cp); err != nil {
return nil, err
}
}
conn, err := ocpp.NewConnector(log, connector, cp, timeout)
if err != nil {
return nil, err
}
c := &OCPP{
log: log,
conn: conn,
idtag: idtag,
timeout: timeout,
}
c.log.DEBUG.Printf("waiting for chargepoint: %v", connectTimeout)
select {
case <-time.After(connectTimeout):
return nil, api.ErrTimeout
case <-cp.HasConnected():
}
// see who's there
if boot {
conn.TriggerMessageRequest(core.BootNotificationFeatureName)
}
var (
rc = make(chan error, 1)
meterSampleInterval time.Duration
)
keys := []string{
ocpp.KeyNumberOfConnectors,
ocpp.KeyMeterValuesSampledData,
ocpp.KeyMeterValueSampleInterval,
ocpp.KeyConnectorSwitch3to1PhaseSupported,
ocpp.KeyChargingScheduleAllowedChargingRateUnit,
}
_ = keys
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)
}
// sort configuration keys for printing
slices.SortFunc(resp.ConfigurationKey, func(i, j core.ConfigurationKey) int {
return cmp.Compare(i.Key, j.Key)
})
rw := map[bool]string{false: "r/w", true: "r/o"}
for _, opt := range resp.ConfigurationKey {
if opt.Value == nil {
continue
}
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" {
c.chargingRateUnit = types.ChargingRateUnitWatts
}
}
if err != nil {
break
}
}
}
rc <- err
}, nil)
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
}
// configuration activated
c.meterValuesSample = meterValues
}
// get initial meter values and configure sample rate
if c.hasMeasurement(types.MeasurandPowerActiveImport) || c.hasMeasurement(types.MeasurandEnergyActiveImportRegister) {
conn.TriggerMessageRequest(core.MeterValuesFeatureName)
if meterInterval > 0 && meterInterval != meterSampleInterval {
if err := c.configure(ocpp.KeyMeterValueSampleInterval, strconv.Itoa(int(meterInterval.Seconds()))); err != nil {
return nil, err
}
}
// 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)
}
}
// TODO: check for running transaction
return c, conn.Initialized()
}
// Connector returns the connector instance
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 slices.Contains(strings.Split(c.meterValuesSample, ","), string(val))
}
// 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 {
if err == nil {
select {
case err = <-rc:
close(rc)
case <-time.After(c.timeout):
err = api.ErrTimeout
}
}
return err
}
// Status implements the api.Charger interface
func (c *OCPP) Status() (api.ChargeStatus, error) {
return c.conn.Status()
}
// Enabled implements the api.Charger interface
func (c *OCPP) Enabled() (bool, error) {
return c.enabled, nil
}
// Enable implements the api.Charger interface
func (c *OCPP) Enable(enable bool) (err error) {
rc := make(chan error, 1)
txn, err := c.conn.TransactionID()
defer func() {
if err == nil {
c.enabled = enable
}
}()
if enable {
if txn > 0 {
// we have the transaction id, treat as enabled
return nil
}
err = ocpp.Instance().RemoteStartTransaction(c.conn.ChargePoint().ID(), func(resp *core.RemoteStartTransactionConfirmation, err error) {
if err == nil && resp != nil && resp.Status != types.RemoteStartStopStatusAccepted {
err = errors.New(string(resp.Status))
}
rc <- err
}, c.idtag, func(request *core.RemoteStartTransactionRequest) {
connector := c.conn.ID()
request.ConnectorId = &connector
request.ChargingProfile = c.getTxChargingProfile(c.current, 0)
})
} else {
// if no transaction is running, the vehicle may have stopped it (which is ok) or an unknown transaction is running
if txn == 0 {
// we cannot tell if a transaction is really running, so we check the status
status, err := c.Status()
if err != nil {
return err
}
if status == api.StatusC {
return errors.New("cannot disable: unknown transaction running")
}
return nil
}
err = ocpp.Instance().RemoteStopTransaction(c.conn.ChargePoint().ID(), func(resp *core.RemoteStopTransactionConfirmation, err error) {
if err == nil && resp != nil && resp.Status != types.RemoteStartStopStatusAccepted {
err = errors.New(string(resp.Status))
}
rc <- err
}, txn)
}
return c.wait(err, rc)
}
func (c *OCPP) setChargingProfile(profile *types.ChargingProfile) error {
connector := c.conn.ID()
rc := make(chan error, 1)
err := ocpp.Instance().SetChargingProfile(c.conn.ChargePoint().ID(), func(resp *smartcharging.SetChargingProfileConfirmation, err error) {
if err == nil && resp != nil && resp.Status != smartcharging.ChargingProfileStatusAccepted {
err = errors.New(string(resp.Status))
}
rc <- err
}, connector, profile)
return c.wait(err, rc)
}
// updatePeriod sets a single charging schedule period with given current
func (c *OCPP) updatePeriod(current float64) error {
// current period can only be updated if transaction is active
if enabled, err := c.Enabled(); err != nil || !enabled {
return err
}
txn, err := c.conn.TransactionID()
if err != nil {
return err
}
current = math.Trunc(10*current) / 10
err = c.setChargingProfile(c.getTxChargingProfile(current, txn))
if err != nil {
err = fmt.Errorf("set charging profile: %w", err)
}
return err
}
func (c *OCPP) getTxChargingProfile(current float64, transactionId int) *types.ChargingProfile {
phases := c.phases
period := types.NewChargingSchedulePeriod(0, current)
if c.chargingRateUnit == types.ChargingRateUnitWatts {
// get (expectedly) active phases from loadpoint
if c.lp != nil {
phases = c.lp.GetPhases()
}
if phases == 0 {
phases = 3
}
period = types.NewChargingSchedulePeriod(0, math.Trunc(230.0*current*float64(phases)))
}
// OCPP assumes phases == 3 if not set
if phases != 0 {
period.NumberPhases = &phases
}
return &types.ChargingProfile{
ChargingProfileId: 1,
TransactionId: transactionId,
StackLevel: 0,
ChargingProfilePurpose: types.ChargingProfilePurposeTxProfile,
ChargingProfileKind: types.ChargingProfileKindRelative,
ChargingSchedule: &types.ChargingSchedule{
ChargingRateUnit: c.chargingRateUnit,
ChargingSchedulePeriod: []types.ChargingSchedulePeriod{period},
},
}
}
// MaxCurrent implements the api.Charger interface
func (c *OCPP) MaxCurrent(current int64) error {
return c.MaxCurrentMillis(float64(current))
}
var _ api.ChargerEx = (*OCPP)(nil)
// MaxCurrentMillis implements the api.ChargerEx interface
func (c *OCPP) MaxCurrentMillis(current float64) error {
err := c.updatePeriod(current)
if err == nil {
c.current = current
}
return err
}
// CurrentPower implements the api.Meter interface
func (c *OCPP) currentPower() (float64, error) {
return c.conn.CurrentPower()
}
// TotalEnergy implements the api.MeterTotal interface
func (c *OCPP) totalEnergy() (float64, error) {
return c.conn.TotalEnergy()
}
// Currents implements the api.PhaseCurrents interface
func (c *OCPP) currents() (float64, float64, float64, error) {
return c.conn.Currents()
}
// Phases1p3p implements the api.PhaseSwitcher interface
func (c *OCPP) phases1p3p(phases int) error {
c.phases = phases
// NOTE: this will currently _never_ do anything since
// loadpoint disabled the charger before switching so
// updatePeriod will short-circuit
return c.updatePeriod(c.current)
}
// // Identify implements the api.Identifier interface
// Unless charger uses vehicle ID as idTag in authorize.req it is not possible to implement this in ocpp1.6
// func (c *OCPP) Identify() (string, error) {
// return "", errors.New("not implemented")
// }
var _ loadpoint.Controller = (*OCPP)(nil)
// LoadpointControl implements loadpoint.Controller
func (c *OCPP) LoadpointControl(lp loadpoint.API) {
c.lp = lp
}