358 lines
10 KiB
Go
358 lines
10 KiB
Go
package charger
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/benbjohnson/clock"
|
|
"github.com/evcc-io/evcc/api"
|
|
"github.com/evcc-io/evcc/charger/ocpp"
|
|
ocppapi "github.com/lorenzodonini/ocpp-go/ocpp"
|
|
ocpp16 "github.com/lorenzodonini/ocpp-go/ocpp1.6"
|
|
"github.com/lorenzodonini/ocpp-go/ocpp1.6/core"
|
|
"github.com/lorenzodonini/ocpp-go/ocpp1.6/firmware"
|
|
"github.com/lorenzodonini/ocpp-go/ocpp1.6/localauth"
|
|
"github.com/lorenzodonini/ocpp-go/ocpp1.6/remotetrigger"
|
|
"github.com/lorenzodonini/ocpp-go/ocpp1.6/reservation"
|
|
"github.com/lorenzodonini/ocpp-go/ocpp1.6/smartcharging"
|
|
"github.com/lorenzodonini/ocpp-go/ocpp1.6/types"
|
|
"github.com/lorenzodonini/ocpp-go/ocppj"
|
|
"github.com/lorenzodonini/ocpp-go/ws"
|
|
"github.com/stretchr/testify/suite"
|
|
)
|
|
|
|
const ocppTestConnectTimeout = 10 * time.Second
|
|
|
|
// ocppTestUrl is derived from the actual bound port in SetupSuite: the central
|
|
// system binds an ephemeral port to avoid bind failures when the fixed default
|
|
// port is already in use on the CI runner.
|
|
var ocppTestUrl string
|
|
|
|
// testLogger receives all ocppj logging for this binary
|
|
var testLogger ocppLogger
|
|
|
|
// suiteRuns counts suite runs, see stationID
|
|
var suiteRuns atomic.Int64
|
|
|
|
func TestMain(m *testing.M) {
|
|
ocpp.Timeout = 5 * time.Second
|
|
|
|
// the simulated charge points never send a spontaneous BootNotification,
|
|
// so shorten the proactive-trigger delay to avoid waiting 5s per charge point
|
|
ocpp.TriggerBootDelay = 100 * time.Millisecond
|
|
|
|
// bind the OCPP central system to an ephemeral port so this test binary
|
|
// does not contend with the charger/ocpp package test binary for the fixed
|
|
// default port when both run in parallel under `go test ./...`
|
|
ocpp.NewServer(ocpp.Config{Port: 0}, "")
|
|
|
|
// after NewServer, which registers the central system as ocppj logger itself
|
|
ocppj.SetLogger(&testLogger)
|
|
|
|
os.Exit(m.Run())
|
|
}
|
|
|
|
func TestOcpp(t *testing.T) {
|
|
suite.Run(t, new(ocppTestSuite))
|
|
}
|
|
|
|
type ocppTestSuite struct {
|
|
suite.Suite
|
|
clock *clock.Mock
|
|
run int64
|
|
}
|
|
|
|
func (suite *ocppTestSuite) SetupSuite() {
|
|
suite.run = suiteRuns.Add(1)
|
|
|
|
cs, err := ocpp.Instance()
|
|
suite.Require().NoError(err, "instance")
|
|
suite.Require().NotNil(cs)
|
|
|
|
testLogger.open(suite.T())
|
|
|
|
suite.Require().NotZero(ocpp.Port(), "central system did not bind")
|
|
ocppTestUrl = fmt.Sprintf("ws://localhost:%d", ocpp.Port())
|
|
|
|
suite.clock = clock.NewMock()
|
|
}
|
|
|
|
func (suite *ocppTestSuite) TearDownSuite() {
|
|
testLogger.close()
|
|
}
|
|
|
|
// stationID qualifies a charge point id with the suite run. The central system is
|
|
// a package global that keeps its registrations, so re-running the suite in the
|
|
// same binary (`go test -count>1`) would otherwise hand the second run the first
|
|
// run's stale, already disconnected charge points.
|
|
func (suite *ocppTestSuite) stationID(id string) string {
|
|
return fmt.Sprintf("%s-%d", id, suite.run)
|
|
}
|
|
|
|
func (suite *ocppTestSuite) startChargePoint(id string, connectorId int) (ocpp16.ChargePoint, *ocppj.Client, func()) {
|
|
// Buffered generously: the handlers in ocpp_test_handler.go send to
|
|
// triggerC synchronously (via defer) on the charge point's WebSocket
|
|
// read-loop goroutine. If that send blocks, the read loop cannot deliver
|
|
// the CALL_RESULT for the CP→CS request the drain goroutine is waiting
|
|
// on, deadlocking the test. The buffer keeps the send from blocking
|
|
// until the drain catches up.
|
|
handler := &ChargePointHandler{
|
|
triggerC: make(chan remotetrigger.MessageTrigger, 16),
|
|
}
|
|
|
|
// ocppj endpoint with handler
|
|
client := ws.NewClient()
|
|
client.SetRequestedSubProtocol(types.V16Subprotocol)
|
|
dispatcher := ocppj.NewDefaultClientDispatcher(ocppj.NewFIFOClientQueue(0))
|
|
endpoint := ocppj.NewClient(id, client, dispatcher, nil, core.Profile, localauth.Profile, firmware.Profile, reservation.Profile, remotetrigger.Profile, smartcharging.Profile)
|
|
|
|
// create charge point with handler
|
|
cp := ocpp16.NewChargePoint(id, endpoint, client)
|
|
cp.SetCoreHandler(handler)
|
|
cp.SetRemoteTriggerHandler(handler)
|
|
cp.SetSmartChargingHandler(handler)
|
|
|
|
// let cs handle the trigger messages; exit on done so we do not leak a
|
|
// drain goroutine into subsequent subtests on the shared ocpp.Instance().
|
|
// triggerC is deliberately left open: the handlers in ocpp_test_handler.go
|
|
// send to it from the charge point's read loop, which we do not synchronize
|
|
// with on shutdown, so closing it could panic on `send on closed channel`.
|
|
done := make(chan struct{})
|
|
finished := make(chan struct{})
|
|
|
|
// mu keeps the drain from talking to a stopped charge point; it still
|
|
// consumes triggerC afterwards so the read loop never blocks on a full buffer
|
|
var mu sync.Mutex
|
|
var stopped bool
|
|
|
|
go func() {
|
|
defer close(finished)
|
|
for {
|
|
select {
|
|
case <-done:
|
|
return
|
|
case msg := <-handler.triggerC:
|
|
mu.Lock()
|
|
if !stopped {
|
|
suite.handleTrigger(cp, connectorId, msg)
|
|
}
|
|
mu.Unlock()
|
|
}
|
|
}
|
|
}()
|
|
|
|
// quiesce the drain before disconnecting: stopping the charge point while a
|
|
// request is in flight closes the dispatcher channel it is sending on
|
|
stop := sync.OnceFunc(func() {
|
|
mu.Lock()
|
|
stopped = true
|
|
mu.Unlock()
|
|
|
|
if cp.IsConnected() {
|
|
cp.Stop()
|
|
}
|
|
|
|
close(done)
|
|
// wait for the drain goroutine to fully exit before the test method
|
|
// returns: handleTrigger logs via suite.T(), which panics if called
|
|
// after the test has completed.
|
|
<-finished
|
|
})
|
|
|
|
suite.T().Cleanup(stop)
|
|
|
|
return cp, endpoint, stop
|
|
}
|
|
|
|
func (suite *ocppTestSuite) handleTrigger(cp ocpp16.ChargePoint, connectorId int, msg remotetrigger.MessageTrigger) {
|
|
switch msg {
|
|
case core.BootNotificationFeatureName:
|
|
if _, err := cp.BootNotification("model", "vendor"); err != nil {
|
|
suite.T().Log("BootNotification:", err)
|
|
}
|
|
|
|
case core.ChangeAvailabilityFeatureName:
|
|
fallthrough
|
|
|
|
case core.StatusNotificationFeatureName:
|
|
if _, err := cp.StatusNotification(connectorId, core.NoError, core.ChargePointStatusCharging); err != nil {
|
|
suite.T().Log("StatusNotification:", err)
|
|
}
|
|
|
|
case core.MeterValuesFeatureName:
|
|
if _, err := cp.MeterValues(connectorId, []types.MeterValue{
|
|
{
|
|
Timestamp: types.NewDateTime(suite.clock.Now()),
|
|
SampledValue: []types.SampledValue{
|
|
{Measurand: types.MeasurandPowerActiveImport, Value: "1000"},
|
|
{Measurand: types.MeasurandEnergyActiveImportRegister, Value: "1.2", Unit: "kWh"},
|
|
},
|
|
},
|
|
}); err != nil {
|
|
suite.T().Log("MeterValues:", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (suite *ocppTestSuite) TestConnect() {
|
|
id1 := suite.stationID("test-1")
|
|
|
|
// 1st charge point- remote
|
|
cp1, _, _ := suite.startChargePoint(id1, 1)
|
|
suite.Require().NoError(cp1.Start(ocppTestUrl))
|
|
suite.Require().True(cp1.IsConnected())
|
|
|
|
// 1st charge point- local
|
|
c1, err := NewOCPP(suite.T().Context(), id1, 1, "", "", 0, false, false, false, true, false, ocppTestConnectTimeout)
|
|
suite.Require().NoError(err)
|
|
|
|
// status and meter values
|
|
{
|
|
suite.clock.Add(ocpp.Timeout)
|
|
c1.conn.TestClock(suite.clock)
|
|
|
|
// status
|
|
_, err = c1.Status()
|
|
suite.Require().NoError(err)
|
|
}
|
|
|
|
// takeover
|
|
{
|
|
expectedTxn := 99
|
|
|
|
// always accept stopping unknown transaction, see https://github.com/evcc-io/evcc/pull/13990
|
|
_, err := cp1.StopTransaction(0, types.NewDateTime(suite.clock.Now()), expectedTxn)
|
|
suite.Require().NoError(err)
|
|
|
|
_, err = cp1.MeterValues(1, []types.MeterValue{
|
|
{
|
|
Timestamp: types.NewDateTime(suite.clock.Now()),
|
|
SampledValue: []types.SampledValue{
|
|
{Measurand: types.MeasurandPowerActiveImport, Value: "1000"},
|
|
},
|
|
},
|
|
}, func(request *core.MeterValuesRequest) {
|
|
request.TransactionId = &expectedTxn
|
|
})
|
|
suite.Require().NoError(err)
|
|
|
|
conn1 := c1.Connector()
|
|
txnId, err := conn1.TransactionID()
|
|
suite.Require().NoError(err)
|
|
suite.Equal(expectedTxn, txnId)
|
|
|
|
res, err := cp1.StopTransaction(0, types.NewDateTime(suite.clock.Now()), expectedTxn)
|
|
suite.Require().NoError(err)
|
|
suite.Equal(types.AuthorizationStatusAccepted, res.IdTagInfo.Status)
|
|
}
|
|
|
|
id2 := suite.stationID("test-2")
|
|
|
|
// 2nd charge point - remote
|
|
cp2, _, stopCp2 := suite.startChargePoint(id2, 1)
|
|
suite.Require().NoError(cp2.Start(ocppTestUrl))
|
|
suite.Require().True(cp2.IsConnected())
|
|
|
|
// 2nd charge point - local
|
|
c2, err := NewOCPP(suite.T().Context(), id2, 1, "", "", 0, false, false, false, true, false, ocppTestConnectTimeout)
|
|
suite.Require().NoError(err)
|
|
|
|
{
|
|
suite.clock.Add(ocpp.Timeout)
|
|
c2.conn.TestClock(suite.clock)
|
|
|
|
// status
|
|
_, err = c2.Status()
|
|
suite.Require().NoError(err)
|
|
}
|
|
|
|
// error on unconfigured 2nd charge point
|
|
cp3, _, _ := suite.startChargePoint(suite.stationID("unconfigured"), 1)
|
|
_, err = cp3.BootNotification("model", "vendor")
|
|
suite.Require().Error(err)
|
|
|
|
// disconnect charge point
|
|
stopCp2()
|
|
suite.Require().False(cp2.IsConnected())
|
|
|
|
t := time.NewTimer(100 * time.Millisecond)
|
|
WAIT_DISCONNECT:
|
|
for {
|
|
select {
|
|
case <-t.C:
|
|
suite.Fail("disconnect timeout")
|
|
case <-time.After(10 * time.Millisecond):
|
|
if _, err := c2.Status(); errors.Is(err, api.ErrTimeout) {
|
|
break WAIT_DISCONNECT
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (suite *ocppTestSuite) TestAutoStart() {
|
|
id1 := suite.stationID("test-3")
|
|
|
|
// 1st charge point- remote
|
|
cp1, _, _ := suite.startChargePoint(id1, 1)
|
|
suite.Require().NoError(cp1.Start(ocppTestUrl))
|
|
suite.Require().True(cp1.IsConnected())
|
|
|
|
// 1st charge point- local
|
|
c1, err := NewOCPP(suite.T().Context(), id1, 1, "", "", 0, false, false, false, false, false, ocppTestConnectTimeout)
|
|
suite.Require().NoError(err)
|
|
|
|
// status and meter values
|
|
{
|
|
suite.clock.Add(ocpp.Timeout)
|
|
c1.conn.TestClock(suite.clock)
|
|
}
|
|
|
|
// acquire
|
|
{
|
|
expectedIdTag := "tag"
|
|
|
|
// always accept stopping unknown transaction, see https://github.com/evcc-io/evcc/pull/13990
|
|
_, err := cp1.StartTransaction(1, expectedIdTag, 0, types.NewDateTime(suite.clock.Now()))
|
|
suite.Require().NoError(err)
|
|
|
|
id, err := c1.Identify()
|
|
suite.Require().NoError(err)
|
|
suite.Require().Equal([]string{expectedIdTag}, id)
|
|
|
|
conn1 := c1.Connector()
|
|
_, err = conn1.TransactionID()
|
|
suite.Require().NoError(err)
|
|
}
|
|
|
|
err = c1.Enable(true)
|
|
suite.Require().NoError(err)
|
|
|
|
err = c1.Enable(false)
|
|
suite.Require().NoError(err)
|
|
}
|
|
|
|
func (suite *ocppTestSuite) TestTimeout() {
|
|
id1 := suite.stationID("test-4")
|
|
|
|
// 1st charge point- remote
|
|
cp1, ocppjClient, _ := suite.startChargePoint(id1, 1)
|
|
suite.Require().NoError(cp1.Start(ocppTestUrl))
|
|
suite.Require().True(cp1.IsConnected())
|
|
|
|
handler := ocppjClient.GetRequestHandler()
|
|
ocppjClient.SetRequestHandler(func(request ocppapi.Request, requestId string, action string) {
|
|
if action != core.ChangeAvailabilityFeatureName {
|
|
handler(request, requestId, action)
|
|
}
|
|
})
|
|
|
|
// 1st charge point- local
|
|
_, err := NewOCPP(suite.T().Context(), id1, 1, "", "", 0, false, false, false, false, false, ocppTestConnectTimeout)
|
|
|
|
suite.Require().NoError(err)
|
|
}
|