OCPP: use TxDefaultProfile, add more apis (BC) (#14888)

This commit is contained in:
premultiply 2024-07-28 22:03:05 +02:00 • committed by GitHub
parent 6cefd83f10
commit 1d5a068859
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1242 additions and 141 deletions

View file

@ -24,18 +24,17 @@ type OCPP struct {
log *util.Logger
conn *ocpp.Connector
idtag string
enabled bool
phases int
current float64
meterValuesSample string
timeout time.Duration
phaseSwitching bool
autoStart, noStop bool
remoteStart bool
chargingRateUnit types.ChargingRateUnitType
lp loadpoint.API
}
const defaultIdTag = "evcc"
const defaultIdTag = "evcc" // RemoteStartTransaction only
func init() {
registry.Add("ocpp", NewOCPPFromConfig)
@ -54,8 +53,9 @@ func NewOCPPFromConfig(other map[string]interface{}) (api.Charger, error) {
BootNotification *bool
GetConfiguration *bool
ChargingRateUnit string
AutoStart bool
NoStop bool
AutoStart bool // deprecated, to be removed
NoStop bool // deprecated, to be removed
RemoteStart bool
}{
Connector: 1,
IdTag: defaultIdTag,
@ -73,7 +73,7 @@ func NewOCPPFromConfig(other map[string]interface{}) (api.Charger, error) {
c, err := NewOCPP(cc.StationId, cc.Connector, cc.IdTag,
cc.MeterValues, cc.MeterInterval,
boot, noConfig, cc.AutoStart, cc.NoStop,
boot, noConfig, cc.RemoteStart,
cc.ConnectTimeout, cc.Timeout, cc.ChargingRateUnit)
if err != nil {
return c, err
@ -94,20 +94,35 @@ func NewOCPPFromConfig(other map[string]interface{}) (api.Charger, error) {
currentsG = c.currents
}
var voltagesG func() (float64, float64, float64, error)
if c.hasMeasurement(types.MeasurandVoltage + ".L3") {
voltagesG = c.voltages
}
var phasesS func(int) error
if c.phaseSwitching {
phasesS = c.phases1p3p
}
return decorateOCPP(c, powerG, totalEnergyG, currentsG, phasesS), nil
var socG func() (float64, error)
if c.hasMeasurement(types.MeasueandSoC) { // typo in ocpp-go
socG = c.soc
}
//var currentG func() (float64, error)
//if c.hasMeasurement(types.MeasurandCurrentOffered) {
// currentG = c.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.PhaseSwitcher,Phases1p3p,func(int) error"
//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, autoStart, noStop bool,
boot, noConfig, remoteStart bool,
connectTimeout, timeout time.Duration,
chargingRateUnit string,
) (*OCPP, error) {
@ -135,12 +150,11 @@ func NewOCPP(id string, connector int, idtag string,
}
c := &OCPP{
log: log,
conn: conn,
idtag: idtag,
autoStart: autoStart,
noStop: noStop,
timeout: timeout,
log: log,
conn: conn,
idtag: idtag,
remoteStart: remoteStart,
timeout: timeout,
}
c.log.DEBUG.Printf("waiting for chargepoint: %v", connectTimeout)
@ -232,7 +246,7 @@ func NewOCPP(id string, connector int, idtag string,
}
case ocpp.KeyChargingScheduleAllowedChargingRateUnit:
if *opt.Value == "W" {
if *opt.Value == "W" || *opt.Value == "Power" {
c.chargingRateUnit = types.ChargingRateUnitWatts
}
}
@ -329,92 +343,56 @@ func (c *OCPP) wait(err error, rc chan error) error {
// Status implements the api.Charger interface
func (c *OCPP) Status() (api.ChargeStatus, error) {
if c.remoteStart {
needtxn, err := c.conn.NeedsTransaction()
if err != nil {
return api.StatusNone, err
}
if needtxn {
// lock the cable by starting remote transaction after vehicle connected
if err := c.initTransaction(); err != nil {
return api.StatusNone, err
}
}
}
return c.conn.Status()
}
// Enabled implements the api.Charger interface
func (c *OCPP) Enabled() (bool, error) {
return c.enabled, nil
current, err := c.getCurrent()
return current > 0, err
}
func (c *OCPP) Enable(enable bool) error {
txn, err := c.conn.TransactionID()
if err != nil {
return err
}
if c.autoStart || (c.noStop && txn > 0) {
// if there is no transaction running, this is a no-op
if txn > 0 {
err = c.enableProfile(enable)
}
} else {
err = c.enableRemote(enable)
}
if err == nil {
c.enabled = enable
}
return err
}
// enableProfile pauses/resumes existing transaction by profile update
func (c *OCPP) enableProfile(enable bool) error {
var current float64
if enable {
current = c.current
}
return c.updatePeriod(current)
return c.setCurrent(current)
}
// enableRemote starts and terminates transaction by RemoteStart/Stop
func (c *OCPP) enableRemote(enable bool) error {
txn, err := c.conn.TransactionID()
if err != nil {
return err
}
func (c *OCPP) initTransaction() error {
rc := make(chan error, 1)
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))
}
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
request.ChargingProfile = c.getTxChargingProfile(c.current, 0)
})
} else {
if txn == 0 {
// we have no transaction id, treat as disabled
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)
}
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 {
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 {
@ -422,26 +400,14 @@ func (c *OCPP) setChargingProfile(profile *types.ChargingProfile) error {
}
rc <- err
}, connector, profile)
}, c.conn.ID(), profile)
return c.wait(err, rc)
}
// updatePeriod sets a single charging schedule period with given current
func (c *OCPP) updatePeriod(current float64) error {
txn, err := c.conn.TransactionID()
if err != nil {
return err
}
// current period can only be updated if transaction is active
if txn == 0 {
return nil
}
current = math.Trunc(10*current) / 10
err = c.setChargingProfile(c.getTxChargingProfile(current, txn))
// 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)
}
@ -449,7 +415,38 @@ func (c *OCPP) updatePeriod(current float64) error {
return err
}
func (c *OCPP) getTxChargingProfile(current float64, transactionId int) *types.ChargingProfile {
// getCurrent returns the internal current offered by the chargepoint
func (c *OCPP) getCurrent() (float64, error) {
var current float64
if c.hasMeasurement(types.MeasurandCurrentOffered) {
return c.getMaxCurrent()
}
// fallback to GetCompositeSchedule request
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 {
current = resp.ChargingSchedule.ChargingSchedulePeriod[0].Limit
} else {
err = fmt.Errorf("invalid ChargingSchedule")
}
}
rc <- err
}, c.conn.ID(), 1)
err = c.wait(err, rc)
return current, err
}
func (c *OCPP) createTxDefaultChargingProfile(current float64) *types.ChargingProfile {
phases := c.phases
period := types.NewChargingSchedulePeriod(0, current)
if c.chargingRateUnit == types.ChargingRateUnitWatts {
@ -469,10 +466,9 @@ func (c *OCPP) getTxChargingProfile(current float64, transactionId int) *types.C
}
return &types.ChargingProfile{
ChargingProfileId: 1,
TransactionId: transactionId,
ChargingProfileId: 0,
StackLevel: 0,
ChargingProfilePurpose: types.ChargingProfilePurposeTxProfile,
ChargingProfilePurpose: types.ChargingProfilePurposeTxDefaultProfile,
ChargingProfileKind: types.ChargingProfileKindRelative,
ChargingSchedule: &types.ChargingSchedule{
ChargingRateUnit: c.chargingRateUnit,
@ -490,38 +486,52 @@ var _ api.ChargerEx = (*OCPP)(nil)
// MaxCurrentMillis implements the api.ChargerEx interface
func (c *OCPP) MaxCurrentMillis(current float64) error {
err := c.updatePeriod(current)
err := c.setCurrent(current)
if err == nil {
c.current = current
}
return err
}
// CurrentPower implements the api.Meter interface
// getMaxCurrent implements the api.CurrentGetter interface
func (c *OCPP) getMaxCurrent() (float64, error) {
return c.conn.GetMaxCurrent()
}
// currentPower implements the api.Meter interface
func (c *OCPP) currentPower() (float64, error) {
return c.conn.CurrentPower()
}
// TotalEnergy implements the api.MeterTotal interface
// totalEnergy implements the api.MeterTotal interface
func (c *OCPP) totalEnergy() (float64, error) {
return c.conn.TotalEnergy()
}
// Currents implements the api.PhaseCurrents interface
// currents implements the api.PhaseCurrents interface
func (c *OCPP) currents() (float64, float64, float64, error) {
return c.conn.Currents()
}
// Phases1p3p implements the api.PhaseSwitcher interface
// voltages implements the api.PhaseVoltages interface
func (c *OCPP) voltages() (float64, float64, float64, error) {
return c.conn.Voltages()
}
// 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)
return c.setCurrent(c.current)
}
// soc implements the api.Battery interface
func (c *OCPP) soc() (float64, error) {
return c.conn.Soc()
}
var _ api.Identifier = (*OCPP)(nil)
// Identify implements the api.Identifier interface
func (c *OCPP) Identify() (string, error) {
return c.conn.IdTag(), nil

View file

@ -158,12 +158,45 @@ func (conn *Connector) Status() (api.ChargeStatus, error) {
return res, nil
}
// NeedsTransaction checks if an initial RemoteStart of a transaction is required
func (conn *Connector) NeedsTransaction() (bool, error) {
if !conn.cp.Connected() {
return false, api.ErrTimeout
}
conn.mu.Lock()
defer conn.mu.Unlock()
return conn.txnId == 0 && conn.status.Status == core.ChargePointStatusPreparing, nil
}
// isMeterTimeout checks if meter values are outdated.
// Must only be called while holding lock.
func (conn *Connector) isMeterTimeout() bool {
return conn.timeout > 0 && conn.clock.Since(conn.meterUpdated) > conn.timeout
}
func (conn *Connector) GetMaxCurrent() (float64, error) {
if !conn.cp.Connected() {
return 0, api.ErrTimeout
}
conn.mu.Lock()
defer conn.mu.Unlock()
// fallthrough for last value on timeout when no transaction is running
if conn.txnId != 0 && conn.isMeterTimeout() {
return 0, api.ErrTimeout
}
if m, ok := conn.measurements[types.MeasurandCurrentOffered]; ok {
f, err := strconv.ParseFloat(m.Value, 64)
return scale(f, m.Unit) / 1e3, err
}
return 0, api.ErrNotAvailable
}
var _ api.Meter = (*Connector)(nil)
func (conn *Connector) CurrentPower() (float64, error) {
@ -174,7 +207,7 @@ func (conn *Connector) CurrentPower() (float64, error) {
conn.mu.Lock()
defer conn.mu.Unlock()
// zero value on timeout when not charging
// zero value on timeout when no transaction is running
if conn.isMeterTimeout() {
if conn.txnId != 0 {
return 0, api.ErrTimeout
@ -201,7 +234,7 @@ func (conn *Connector) TotalEnergy() (float64, error) {
conn.mu.Lock()
defer conn.mu.Unlock()
// fallthrough for last value on timeout when not charging
// fallthrough for last value on timeout when no transaction is running
if conn.txnId != 0 && conn.isMeterTimeout() {
return 0, api.ErrTimeout
}
@ -214,6 +247,27 @@ func (conn *Connector) TotalEnergy() (float64, error) {
return 0, api.ErrNotAvailable
}
func (conn *Connector) Soc() (float64, error) {
if !conn.cp.Connected() {
return 0, api.ErrTimeout
}
conn.mu.Lock()
defer conn.mu.Unlock()
// fallthrough for last value on timeout when no transaction is running
if conn.txnId != 0 && conn.isMeterTimeout() {
return 0, api.ErrTimeout
}
if m, ok := conn.measurements[types.MeasueandSoC]; ok { // typo in ocpp-go
f, err := strconv.ParseFloat(m.Value, 64)
return scale(f, m.Unit) / 1e3, err
}
return 0, api.ErrNotAvailable
}
func scale(f float64, scale types.UnitOfMeasure) float64 {
switch {
case strings.HasPrefix(string(scale), "k"):
@ -239,7 +293,7 @@ func (conn *Connector) Currents() (float64, float64, float64, error) {
conn.mu.Lock()
defer conn.mu.Unlock()
// zero value on timeout when not charging
// zero value on timeout when no transaction is running
if conn.isMeterTimeout() {
if conn.txnId != 0 {
return 0, 0, 0, api.ErrTimeout
@ -266,3 +320,35 @@ func (conn *Connector) Currents() (float64, float64, float64, error) {
return currents[0], currents[1], currents[2], nil
}
func (conn *Connector) Voltages() (float64, float64, float64, error) {
if !conn.cp.Connected() {
return 0, 0, 0, api.ErrTimeout
}
conn.mu.Lock()
defer conn.mu.Unlock()
// fallthrough for last value on timeout when no transaction is running
if conn.txnId != 0 && conn.isMeterTimeout() {
return 0, 0, 0, api.ErrTimeout
}
voltages := make([]float64, 0, 3)
for phase := 1; phase <= 3; phase++ {
m, ok := conn.measurements[getPhaseKey(types.MeasurandVoltage, phase)]
if !ok {
return 0, 0, 0, api.ErrNotAvailable
}
f, err := strconv.ParseFloat(m.Value, 64)
if err != nil {
return 0, 0, 0, fmt.Errorf("invalid voltage for phase %d: %w", phase, err)
}
voltages = append(voltages, scale(f, m.Unit))
}
return voltages[0], voltages[1], voltages[2], nil
}

File diff suppressed because it is too large Load diff

View file

@ -100,7 +100,7 @@ func (suite *ocppTestSuite) TestConnect() {
suite.Require().True(cp1.IsConnected())
// 1st charge point- local
c1, err := NewOCPP("test-1", 1, "", "", 0, false, false, false, false, ocppTestConnectTimeout, ocppTestTimeout, "A")
c1, err := NewOCPP("test-1", 1, "", "", 0, false, false, true, ocppTestConnectTimeout, ocppTestTimeout, "A")
suite.Require().NoError(err)
// status and meter values
@ -159,7 +159,7 @@ func (suite *ocppTestSuite) TestConnect() {
suite.Require().True(cp2.IsConnected())
// 2nd charge point - local
c2, err := NewOCPP("test-2", 1, "", "", 0, false, false, false, false, ocppTestConnectTimeout, ocppTestTimeout, "A")
c2, err := NewOCPP("test-2", 1, "", "", 0, false, false, true, ocppTestConnectTimeout, ocppTestTimeout, "A")
suite.Require().NoError(err)
{
@ -201,7 +201,7 @@ func (suite *ocppTestSuite) TestAutoStart() {
suite.Require().True(cp1.IsConnected())
// 1st charge point- local
c1, err := NewOCPP("test-3", 1, "", "", 0, false, false, true, true, ocppTestConnectTimeout, ocppTestTimeout, "A")
c1, err := NewOCPP("test-3", 1, "", "", 0, false, false, false, ocppTestConnectTimeout, ocppTestTimeout, "A")
suite.Require().NoError(err)
// status and meter values

View file

@ -12,8 +12,11 @@ requirements:
Standardmäßig wird die erste eingehende Verbindung mit einer beliebigen Ladepunktkennung verwendet.
Um mehrere Ladepunkte eindeutig zuordnen zu können müssen die jeweilige Stationskennung (`stationid: `) und Anschlussnummer (`connector: `) hinterlegt werden.
Viele Wallboxen fügen die `stationid` automatisch der Backend-URL hinzu, bei manchen muss dies händisch geschehen `ws://<evcc>:8887/<stationid>`.
Gegebenenfalls benötigt der Ladepunkt eine vorkonfigurierte (virtuelle) Token-ID/RFID-Kennung (`idtag: `) mit der die Ladevorgänge ohne Authentifizierung gestartet werden können.
Für Zählermesswerte sollte in der Wallbox ein kurzes Zeitintervall konfiguriert werden.
Nutzen Sie Ihre RFID-Tags (dies ermöglicht z.B. eine Fahrzeugidentifizierung) oder setzen Sie Ihre Wallbox auf "freies Laden" oder "Autostart" um die jeweils für die Ladefreigabe benötigte Transaktion zu erzeugen.
Falls die Wallbox keinerlei Option bietet die Transaktionen lokal zu starten, kann die Option `remotetransaction` genutzt werden um automatisch eine Transaktion zu starten sobald ein Ladekabel angeschlossen wird.
Dies sollte nur in Ausnahmefällen erforderlich sein.
Voraussetzungen:
* Ggf. zuvor konfigurierte OCPP-Profile (z.B. durch eine andere Backend-Anbindung) in der Wallboxkonfiguration entfernen
@ -27,8 +30,11 @@ requirements:
By default, the first incoming connection with any station identifier is used.
In order to be able to clearly assign several charging points, the respective station identifier (`stationid: `) and connector number (`connector: `) must be configured.
Many wallboxes automatically add the `station id` to the backend URL, some have to do this manually `ws://<evcc>:8887/<stationid>`.
The charger may need a pre-configured (virtual) token ID/RFID identifier (`idtag: `) with which the charging sessions can be started without authorization.
If the charger supports sending metering values try to adjust the interval to a short time span.
Use your RFID tags (this allows e.g. vehicle identification) or set your charger to "free charging" or "autostart" to generate the transaction required for charging release.
If the charger does not offer any option to start transactions locally, the `remotetransaction` option can be used to automatically start a transaction as soon as a cable is connected.
This should only be necessary in exceptional cases.
Requirements:
* If necessary, remove previously configured OCPP profiles (e.g. used for a different backend connection) in the charger configuration
@ -39,11 +45,19 @@ requirements:
params:
- preset: ocpp
- name: autostart
advanced: true
type: bool
deprecated: true
- name: nostop
deprecated: true
- name: remotestart
advanced: true
type: bool
default: false
description:
de: Automatisch eine für die Ladefreigabe benötigte Transaktion starten (RemoteStartTransaction) sobald ein Ladekabel mit der Wallbox verbunden wird
en: Automatically start a transaction required for charging release (RemoteStartTransaction) as soon as a cable is connected to the charger
help:
de: Nur verwenden wenn keinerlei Möglichkeit besteht Transaktionen seitens des Ladepunktes zu starten
en: Only use if there is no other way to start transactions from the charger
- name: getconfiguration
advanced: true
type: bool
@ -68,14 +82,14 @@ params:
advanced: true
type: duration
description:
de: Zählerwerte nach Intervall anfordern
en: Interval for requesting meter values
de: Gewünschtes Übertragungsintervall der Zählerwerte
en: Desired transmission interval of meter values
- name: metervalues
advanced: true
type: string
description:
de: Liste der Zählerwerte
en: List of meter values
de: Kommaseparierte Liste der zu sendenden Zählerwerte ("measurand")
en: Comma-separated list of meter values to send ("measurand")
- name: chargingrateunit
advanced: true
type: string
@ -84,8 +98,7 @@ params:
en: Unit for setting ChargingProfile values ("W" or "A")
render: |
{{ include "ocpp" . }}
autostart: {{ .autostart }}
nostop: {{ .nostop }}
remotestart: {{ .remotestart }}
{{- if ne .getconfiguration "true" }}
getconfiguration: {{ .getconfiguration }}
{{- end }}