From 44158b8aecd8720e21afe7c471857aa034a3917d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=BCrz?= Date: Thu, 15 Oct 2026 14:53:11 +0200 Subject: [PATCH] EVerest: Fix initial connection race condition and implement automatic reconnect mechanism --- everest/jsonrpc/everestconnection.cpp | 14 ++- everest/tests/CMakeLists.txt | 18 ++++ everest/tests/tst_everestconnection.cpp | 131 ++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 everest/tests/tst_everestconnection.cpp diff --git a/everest/jsonrpc/everestconnection.cpp b/everest/jsonrpc/everestconnection.cpp index 4feff4e2..634d9445 100644 --- a/everest/jsonrpc/everestconnection.cpp +++ b/everest/jsonrpc/everestconnection.cpp @@ -48,6 +48,18 @@ EverestConnection::EverestConnection(quint16 port, QObject *parent) } }); + // Connection refused or failed init does not change availability, so availableChanged alone never triggers a retry + connect(m_client, &EverestJsonRpcClient::connectionErrorOccurred, this, [this](){ + if (!m_running || m_reconnectTimer.isActive()) + return; + + if (m_monitor && !m_monitor->reachable()) + return; + + qCDebug(dcEverest()) << "Connection error occurred. Starting reconnect timer for JsonRpc client to" << m_client->serverUrl().toString(); + m_reconnectTimer.start(); + }); + // Reconnect timer in case the network device is reachable but the websocketserver not yet m_reconnectTimer.setInterval(5000); m_reconnectTimer.setSingleShot(false); @@ -107,7 +119,7 @@ void EverestConnection::setMonitor(NetworkDeviceMonitor *monitor) connect(m_monitor, &NetworkDeviceMonitor::reachableChanged, this, &EverestConnection::onMonitorReachableChanged); } - if (m_monitor->reachable()) + if (m_monitor && m_monitor->reachable()) onMonitorReachableChanged(true); } diff --git a/everest/tests/CMakeLists.txt b/everest/tests/CMakeLists.txt index 71b59ee9..ee86d2fe 100644 --- a/everest/tests/CMakeLists.txt +++ b/everest/tests/CMakeLists.txt @@ -14,3 +14,21 @@ target_link_libraries(tst_rfidproviderclient PRIVATE Qt${NYMEA_TEST_QT_MAJOR_VERSION}::Test Qt${NYMEA_TEST_QT_MAJOR_VERSION}::Network Qt${NYMEA_TEST_QT_MAJOR_VERSION}::WebSockets) add_test(NAME everest-rfid COMMAND ${DBUS_RUN_SESSION} -- $) + +find_package(PkgConfig REQUIRED) +pkg_check_modules(NYMEA REQUIRED IMPORTED_TARGET nymea) +find_program(NYMEA_PLUGININFO_COMPILER nymea-plugininfocompiler REQUIRED) +add_custom_command( + OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/plugininfo.h ${CMAKE_CURRENT_BINARY_DIR}/extern-plugininfo.h + COMMAND ${NYMEA_PLUGININFO_COMPILER} ${CMAKE_CURRENT_SOURCE_DIR}/../integrationplugineverest.json + -o ${CMAKE_CURRENT_BINARY_DIR}/plugininfo.h -e ${CMAKE_CURRENT_BINARY_DIR}/extern-plugininfo.h + DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/../integrationplugineverest.json) +add_executable(tst_everestconnection tst_everestconnection.cpp + ${CMAKE_CURRENT_BINARY_DIR}/plugininfo.h + ../jsonrpc/everestconnection.cpp ../jsonrpc/everestevse.cpp ../jsonrpc/everestjsonrpcclient.cpp + ../jsonrpc/everestjsonrpcinterface.cpp ../jsonrpc/everestjsonrpcreply.cpp) +target_include_directories(tst_everestconnection PRIVATE .. ${CMAKE_CURRENT_BINARY_DIR}) +target_link_libraries(tst_everestconnection PRIVATE PkgConfig::NYMEA + Qt${NYMEA_TEST_QT_MAJOR_VERSION}::Core Qt${NYMEA_TEST_QT_MAJOR_VERSION}::Test + Qt${NYMEA_TEST_QT_MAJOR_VERSION}::Network Qt${NYMEA_TEST_QT_MAJOR_VERSION}::WebSockets) +add_test(NAME everest-connection COMMAND $) diff --git a/everest/tests/tst_everestconnection.cpp b/everest/tests/tst_everestconnection.cpp new file mode 100644 index 00000000..7f4c2aa9 --- /dev/null +++ b/everest/tests/tst_everestconnection.cpp @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include +#include +#include +#include +#include + +#include "plugininfo.h" +#include "loggingcategories.h" +#include "jsonrpc/everestconnection.h" +#include "jsonrpc/everestjsonrpcclient.h" + +NYMEA_LOGGING_CATEGORY(dcEverestTraffic, "EverestTraffic") + +class FakeMonitor : public NetworkDeviceMonitor +{ + Q_OBJECT +public: + explicit FakeMonitor(QObject *parent = nullptr) : NetworkDeviceMonitor(parent) + { + m_info.setAddress(QHostAddress::LocalHost); + } + + MacAddress macAddress() const override { return MacAddress(); } + QString hostName() const override { return QString(); } + QHostAddress address() const override { return QHostAddress::LocalHost; } + NetworkDeviceInfo::MonitorMode monitorMode() const override { return NetworkDeviceInfo::MonitorModeIp; } + NetworkDeviceInfo networkDeviceInfo() const override { return m_info; } + bool reachable() const override { return true; } + QDateTime lastSeen() const override { return QDateTime::currentDateTime(); } + uint pingRetries() const override { return 0; } + void setPingRetries(uint) override { } + +private: + NetworkDeviceInfo m_info; +}; + +// Minimal RpcApi answering the init sequence of EverestJsonRpcClient +class FakeRpcApi : public QObject +{ + Q_OBJECT +public: + QWebSocketServer server{"fake-everest", QWebSocketServer::NonSecureMode}; + int connectionCount = 0; + + bool listen(quint16 port) + { + connect(&server, &QWebSocketServer::newConnection, this, [this](){ + QWebSocket *socket = server.nextPendingConnection(); + connectionCount++; + connect(socket, &QWebSocket::disconnected, socket, &QWebSocket::deleteLater); + connect(socket, &QWebSocket::textMessageReceived, socket, [socket](const QString &message){ + QVariantMap request = QJsonDocument::fromJson(message.toUtf8()).toVariant().toMap(); + QVariantMap result; + const QString method = request.value("method").toString(); + if (method == "API.Hello") { + result = {{"api_version", "1.0"}, {"everest_version", "test"}, {"authentication_required", false}, {"charger_info", QVariantMap()}}; + } else if (method == "ChargePoint.GetEVSEInfos") { + result = {{"error", "NoError"}, {"infos", QVariantList()}}; + } else { + result = {{"error", "NoError"}}; + } + QVariantMap response = {{"jsonrpc", "2.0"}, {"id", request.value("id")}, {"result", result}}; + socket->sendTextMessage(QString::fromUtf8(QJsonDocument::fromVariant(response).toJson(QJsonDocument::Compact))); + }); + }); + return server.listen(QHostAddress::LocalHost, port); + } +}; + +class TestEverestConnection : public QObject +{ + Q_OBJECT + + static quint16 freePort() + { + QTcpServer probe; + probe.listen(QHostAddress::LocalHost, 0); + return probe.serverPort(); + } + +private slots: + void reconnectAfterConnectionRefused() + { + const quint16 port = freePort(); + QVERIFY(port != 0); + + FakeMonitor monitor; + EverestConnection connection(port); + connection.setMonitor(&monitor); + QSignalSpy errorSpy(connection.client(), &EverestJsonRpcClient::connectionErrorOccurred); + QSignalSpy availableSpy(&connection, &EverestConnection::availableChanged); + + // Server not up yet, like EVerest still booting + connection.start(); + QTRY_VERIFY_WITH_TIMEOUT(errorSpy.count() >= 1, 2000); + QVERIFY(!connection.available()); + + FakeRpcApi api; + QVERIFY(api.listen(port)); + + QTRY_VERIFY_WITH_TIMEOUT(connection.available(), 8000); + QCOMPARE(availableSpy.count(), 1); + QCOMPARE(availableSpy.first().first().toBool(), true); + } + + void noReconnectWhenStopped() + { + const quint16 port = freePort(); + QVERIFY(port != 0); + + FakeMonitor monitor; + EverestConnection connection(port); + connection.setMonitor(&monitor); + QSignalSpy errorSpy(connection.client(), &EverestJsonRpcClient::connectionErrorOccurred); + + connection.start(); + QTRY_VERIFY_WITH_TIMEOUT(errorSpy.count() >= 1, 2000); + connection.stop(); + + FakeRpcApi api; + QVERIFY(api.listen(port)); + + QTest::qWait(6000); + QVERIFY(!connection.available()); + QCOMPARE(api.connectionCount, 0); + } +}; + +QTEST_GUILESS_MAIN(TestEverestConnection) +#include "tst_everestconnection.moc"