evcc-io/charger/ocpp.go

588 lines
16 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
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"
)
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 // 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
}{
Connector: 1,
IdTag: defaultIdTag,
MeterInterval: 10 * time.Second,
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.RemoteStart,
cc.ConnectTimeout, cc.Timeout, cc.ChargingRateUnit)
if err != nil {
return c, err
}
var (
powerG, totalEnergyG, socG func() (float64, error)
currentsG, voltagesG func() (float64, float64, float64, error)
)
if c.hasMeasurement(types.MeasurandPowerActiveImport) {
powerG = c.conn.CurrentPower
}
if c.hasMeasurement(types.MeasurandEnergyActiveImportRegister) {
totalEnergyG = c.conn.TotalEnergy
}
if c.hasMeasurement(types.MeasurandCurrentImport) {
currentsG = c.conn.Currents
}
if c.hasMeasurement(types.MeasurandVoltage) {
voltagesG = c.conn.Voltages
}
if c.hasMeasurement(types.MeasurandSoC) {
socG = c.conn.Soc
}
var phasesS func(int) error
if c.phaseSwitching {
phasesS = c.phases1p3p
}
// var currentG func() (float64, error)
// if c.hasMeasurement(types.MeasurandCurrentOffered) {
// currentG = c.conn.GetMaxCurrent
// }
return decorateOCPP(c, powerG, totalEnergyG, currentsG, voltagesG, phasesS, socG), 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.PhaseVoltages,Voltages,func() (float64, float64, float64, error)" -t "api.PhaseSwitcher,Phases1p3p,func(int) error" -t "api.Battery,Soc,func() (float64, error)"
// NewOCPP creates OCPP charger
func NewOCPP(id string, connector int, idtag string,
meterValues string, meterInterval time.Duration,
boot, noConfig, remoteStart 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,
remoteStart: remoteStart,
chargingRateUnit: types.ChargingRateUnitType(chargingRateUnit),
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)
meterValuesSampledData, meterValuesSampledDataMaxLength, err := c.getConfiguration(cp.ID(), connector)
if err != nil {
return nil, err
}
// see who's there
if c.hasRemoteTriggerFeature {
if err := ocpp.Instance().TriggerMessageRequest(cp.ID(), 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
}
}
}
}
// autodetect measurands
if meterValues == "" && meterValuesSampledDataMaxLength > 0 {
sampledMeasurands := c.tryMeasurands(desiredMeasurands, ocpp.KeyMeterValuesSampledData)
meterValues = strings.Join(sampledMeasurands[:min(len(sampledMeasurands), meterValuesSampledDataMaxLength)], ",")
}
// configure measurands
if meterValues != "" {
if err := c.configure(ocpp.KeyMeterValuesSampledData, meterValues); err == nil {
meterValuesSampledData = meterValues
}
}
c.meterValuesSample = meterValuesSampledData
// 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():
}
}
}
// configure sample rate
if meterInterval > 0 {
if err := c.configure(ocpp.KeyMeterValueSampleInterval, strconv.Itoa(int(meterInterval.Seconds()))); err != nil {
c.log.WARN.Printf("failed configuring MeterValueSampleInterval: %v", err)
}
}
if c.hasRemoteTriggerFeature {
go conn.WatchDog(10 * time.Second)
}
// configure ping interval
c.configure(ocpp.KeyWebSocketPingInterval, "30")
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 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
}
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)
}
// Status implements the api.Charger interface
func (c *OCPP) Status() (api.ChargeStatus, error) {
status, err := c.conn.Status()
if err != nil {
return api.StatusNone, err
}
if c.conn.NeedsAuthentication() {
if c.remoteStart {
// lock the cable by starting remote transaction after vehicle connected
if err := c.initTransaction(); err != nil {
c.log.WARN.Printf("failed to start remote transaction: %v", err)
}
} else {
// TODO: bring this status to UI
c.log.WARN.Printf("waiting for local authentication")
}
}
switch status {
case
core.ChargePointStatusAvailable, // "Available"
core.ChargePointStatusUnavailable: // "Unavailable"
return api.StatusA, nil
case
core.ChargePointStatusPreparing, // "Preparing"
core.ChargePointStatusSuspendedEVSE, // "SuspendedEVSE"
core.ChargePointStatusSuspendedEV, // "SuspendedEV"
core.ChargePointStatusFinishing: // "Finishing"
return api.StatusB, nil
case
core.ChargePointStatusCharging: // "Charging"
return api.StatusC, nil
case
core.ChargePointStatusReserved, // "Reserved"
core.ChargePointStatusFaulted: // "Faulted"
return api.StatusF, fmt.Errorf("chargepoint status: %s", status)
default:
return api.StatusNone, fmt.Errorf("invalid chargepoint status: %s", status)
}
}
var _ api.StatusReasoner = (*OCPP)(nil)
func (c *OCPP) StatusReason() (api.Reason, error) {
var res api.Reason
s, err := c.conn.Status()
if err != nil {
return res, err
}
switch {
case c.conn.NeedsAuthentication():
res = api.ReasonWaitingForAuthorization
case s == core.ChargePointStatusFinishing:
res = api.ReasonDisconnectRequired
}
return res, nil
}
// Enabled implements the api.Charger interface
func (c *OCPP) Enabled() (bool, error) {
if s, err := c.conn.Status(); err == nil {
switch s {
case
core.ChargePointStatusSuspendedEVSE:
return false, nil
case
core.ChargePointStatusCharging,
core.ChargePointStatusSuspendedEV:
return true, nil
}
}
// fallback to the "offered" measurands
if c.hasMeasurement(types.MeasurandCurrentOffered) {
if v, err := c.conn.GetMaxCurrent(); err == nil {
return v > 0, nil
}
}
if c.hasMeasurement(types.MeasurandPowerOffered) {
if v, err := c.conn.GetMaxPower(); err == nil {
return v > 0, nil
}
}
// fallback to querying the active charging profile schedule limit
if v, err := c.getScheduleLimit(); err == nil {
return v > 0, nil
}
// fallback to cached value as last resort
return c.enabled, nil
}
// Enable implements the api.Charger interface
func (c *OCPP) Enable(enable bool) error {
var current float64
if enable {
current = c.current
}
err := c.setCurrent(current)
if err == nil {
// cache enabled state as last fallback option
c.enabled = enable
}
return err
}
func (c *OCPP) initTransaction() error {
rc := make(chan error, 1)
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.effectiveIdTag(), func(request *core.RemoteStartTransactionRequest) {
connector := c.conn.ID()
request.ConnectorId = &connector
})
return c.wait(err, rc)
}
func (c *OCPP) setChargingProfile(profile *types.ChargingProfile) error {
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
}, c.conn.ID(), profile)
return c.wait(err, rc)
}
// setCurrent sets the TxDefaultChargingProfile with given current
func (c *OCPP) setCurrent(current float64) error {
err := c.setChargingProfile(c.createTxDefaultChargingProfile(math.Trunc(10*current) / 10))
if err != nil {
err = fmt.Errorf("set charging profile: %w", err)
}
return err
}
// getScheduleLimit queries the current or power limit the charge point is currently set to offer
func (c *OCPP) getScheduleLimit() (float64, error) {
const duration = 60 // duration of requested schedule in seconds
var limit float64
rc := make(chan error, 1)
err := ocpp.Instance().GetCompositeSchedule(c.conn.ChargePoint().ID(), func(resp *smartcharging.GetCompositeScheduleConfirmation, err error) {
if err == nil && resp != nil && resp.Status != smartcharging.GetCompositeScheduleStatusAccepted {
err = errors.New(string(resp.Status))
}
if err == nil {
if resp.ChargingSchedule != nil && len(resp.ChargingSchedule.ChargingSchedulePeriod) > 0 {
// return first (current) period limit
limit = resp.ChargingSchedule.ChargingSchedulePeriod[0].Limit
} else {
err = fmt.Errorf("invalid ChargingSchedule")
}
}
rc <- err
}, c.conn.ID(), duration)
err = c.wait(err, rc)
return limit, err
}
// createTxDefaultChargingProfile returns a TxDefaultChargingProfile with given current
func (c *OCPP) createTxDefaultChargingProfile(current float64) *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: c.chargingProfileId,
StackLevel: c.stackLevel,
ChargingProfilePurpose: types.ChargingProfilePurposeTxDefaultProfile,
ChargingProfileKind: types.ChargingProfileKindAbsolute,
ChargingSchedule: &types.ChargingSchedule{
StartSchedule: types.Now(),
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.setCurrent(current)
if err == nil {
c.current = current
}
return err
}
// phases1p3p implements the api.PhaseSwitcher interface
func (c *OCPP) phases1p3p(phases int) error {
c.phases = phases
return c.setCurrent(c.current)
}
var _ api.Identifier = (*OCPP)(nil)
// Identify implements the api.Identifier interface
func (c *OCPP) Identify() (string, error) {
return c.conn.IdTag(), nil
}
var _ api.Diagnosis = (*OCPP)(nil)
// Diagnose implements the api.Diagnosis interface
func (c *OCPP) Diagnose() {
fmt.Printf("\tCharge Point ID: %s\n", c.conn.ChargePoint().ID())
if c.bootNotification != nil {
fmt.Printf("\tBoot Notification:\n")
fmt.Printf("\t\tChargePointVendor: %s\n", c.bootNotification.ChargePointVendor)
fmt.Printf("\t\tChargePointModel: %s\n", c.bootNotification.ChargePointModel)
fmt.Printf("\t\tChargePointSerialNumber: %s\n", c.bootNotification.ChargePointSerialNumber)
fmt.Printf("\t\tFirmwareVersion: %s\n", c.bootNotification.FirmwareVersion)
}
fmt.Printf("\tConfiguration:\n")
rc := make(chan error, 1)
err := ocpp.Instance().GetConfiguration(c.conn.ChargePoint().ID(), func(resp *core.GetConfigurationConfirmation, err error) {
if err == nil {
// 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
}
fmt.Printf("\t\t%s (%s): %s\n", opt.Key, rw[opt.Readonly], *opt.Value)
}
}
rc <- err
}, nil)
c.wait(err, rc)
}
var _ loadpoint.Controller = (*OCPP)(nil)
// LoadpointControl implements loadpoint.Controller
func (c *OCPP) LoadpointControl(lp loadpoint.API) {
c.lp = lp
}