From 947da7e1fc2edbd49cedcdff4f37e144e8426e93 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 12 Jun 2026 13:49:22 +0200 Subject: [PATCH] feat(v2c): add HTTP LAN discovery (V2cTcpDiscovery) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Architecture choice: Modbus TCP has no network discovery mechanism, so discovery piggybacks on nymea's NetworkDeviceDiscovery (ARP/mDNS scan) and then probes each host via GET /RealTimeData (V2C Trydan HTTP REST API). The presence of a "Paused" field in the JSON response is used as a Trydan fingerprint — it matches the charger model and is absent on generic devices. cf. evcc charger/trydan.go realTimeData struct. Probes run concurrently (one QNetworkReply per host) but the sequence terminates via two independent paths that could both fire: 1. The NetworkDeviceDiscoveryReply::finished callback arms a 3-second QTimer::singleShot to give outstanding probes time to complete. 2. Each probe completion path checks (m_scanFinished && pendingProbes==0) and calls finishDiscovery() early if all probes are already done. A m_finished bool guard prevents double-emission of discoveryFinished() if both paths fire in quick succession (e.g. all probes complete exactly as the timer fires). Manual-entry (createMethod "user") skips discovery entirely; setupThing() is called directly with the user-supplied IP. Co-Authored-By: Claude Sonnet 4.6 --- v2c/v2ctcpdiscovery.cpp | 110 ++++++++++++++++++++++++++++++++++++++++ v2c/v2ctcpdiscovery.h | 61 ++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 v2c/v2ctcpdiscovery.cpp create mode 100644 v2c/v2ctcpdiscovery.h diff --git a/v2c/v2ctcpdiscovery.cpp b/v2c/v2ctcpdiscovery.cpp new file mode 100644 index 0000000..e5a1313 --- /dev/null +++ b/v2c/v2ctcpdiscovery.cpp @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "v2ctcpdiscovery.h" +#include "extern-plugininfo.h" + +#include +#include +#include +#include +#include +#include +#include + +V2cTcpDiscovery::V2cTcpDiscovery(NetworkDeviceDiscovery *networkDeviceDiscovery, + QObject *parent) + : QObject(parent), + m_networkDeviceDiscovery(networkDeviceDiscovery) +{} + +void V2cTcpDiscovery::startDiscovery() +{ + qCInfo(dcV2c()) << "Discovery: searching for V2C Trydan chargers on the network..."; + m_startTime = QDateTime::currentDateTime(); + m_pendingProbes = 0; + m_scanFinished = false; + m_results.clear(); + m_networkDeviceInfos.clear(); + + NetworkDeviceDiscoveryReply *reply = m_networkDeviceDiscovery->discover(); + + connect(reply, &NetworkDeviceDiscoveryReply::hostAddressDiscovered, + this, &V2cTcpDiscovery::probeAddress); + + connect(reply, &NetworkDeviceDiscoveryReply::finished, + reply, &NetworkDeviceDiscoveryReply::deleteLater); + + connect(reply, &NetworkDeviceDiscoveryReply::finished, this, [this, reply]() { + m_networkDeviceInfos = reply->networkDeviceInfos(); + m_scanFinished = true; + + // Give outstanding HTTP probes 3 extra seconds to complete. + QTimer::singleShot(3000, this, &V2cTcpDiscovery::finishDiscovery); + }); +} + +QList V2cTcpDiscovery::results() const +{ + return m_results; +} + +void V2cTcpDiscovery::probeAddress(const QHostAddress &address) +{ + // The V2C Trydan HTTP API: GET /RealTimeData returns JSON with a "Paused" + // field (and ChargeState, ChargePower, etc.). The presence of "Paused" + // is a reliable Trydan fingerprint; it does not appear on generic devices. + // cf. evcc charger/trydan.go realTimeData struct and AGENTS.md § HTTP. + QUrl url; + url.setScheme(QStringLiteral("http")); + url.setHost(address.toString()); + url.setPath(QStringLiteral("/RealTimeData")); + + QNetworkRequest request(url); + request.setTransferTimeout(3000); + + m_pendingProbes++; + QNetworkReply *reply = m_nam.get(request); + + connect(reply, &QNetworkReply::finished, this, [this, reply, address]() { + reply->deleteLater(); + m_pendingProbes--; + + if (reply->error() == QNetworkReply::NoError) { + const QByteArray data = reply->readAll(); + const QJsonObject json = QJsonDocument::fromJson(data).object(); + + // "Paused" is the HTTP-API name for PauseState — note it differs from + // the Modbus register name (cf. AGENTS.md § HTTP). + if (json.contains(QStringLiteral("Paused"))) { + Result result; + result.ipAddress = address.toString(); + result.networkDeviceInfo = m_networkDeviceInfos.get(address); + if (result.networkDeviceInfo.address().isNull()) { + NetworkDeviceInfo info; + info.setAddress(address); + result.networkDeviceInfo = info; + } + qCDebug(dcV2c()) << "Discovery: found V2C Trydan at" << address.toString(); + m_results.append(result); + } + } + + // Finish early if the scan is already done and this was the last probe. + if (m_scanFinished && m_pendingProbes == 0) + finishDiscovery(); + }); +} + +void V2cTcpDiscovery::finishDiscovery() +{ + // Guard against double-emission (can be called by the timer and the last probe). + if (m_finished) + return; + m_finished = true; + + const qint64 ms = QDateTime::currentMSecsSinceEpoch() - m_startTime.toMSecsSinceEpoch(); + qCInfo(dcV2c()) << "Discovery: finished in" + << QTime::fromMSecsSinceStartOfDay(static_cast(ms)).toString("mm:ss.zzz") + << "with" << m_results.count() << "V2C Trydan(s) found."; + emit discoveryFinished(); +} diff --git a/v2c/v2ctcpdiscovery.h b/v2c/v2ctcpdiscovery.h new file mode 100644 index 0000000..0d4b385 --- /dev/null +++ b/v2c/v2ctcpdiscovery.h @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#ifndef V2CTCPDISCOVERY_H +#define V2CTCPDISCOVERY_H + +#include +#include +#include +#include + +#include + +/*! + * \brief Network discovery for V2C Trydan chargers (Modbus TCP). + * + * Uses nymea's NetworkDeviceDiscovery to enumerate LAN hosts, then probes + * each one with \c GET /RealTimeData (the V2C Trydan HTTP REST API) to + * confirm it is a Trydan. The probe is HTTP — not Modbus — because the + * Trydan has no network-accessible Modbus discovery mechanism. + * cf. AGENTS.md § HTTP and evcc charger/trydan.go. + * + * Manual-entry (createMethod "user") bypasses discovery entirely; + * the plugin calls setupThing() directly with the user-supplied IP. + */ +class V2cTcpDiscovery : public QObject +{ + Q_OBJECT + +public: + struct Result { + NetworkDeviceInfo networkDeviceInfo; + QString ipAddress; + }; + + explicit V2cTcpDiscovery(NetworkDeviceDiscovery *networkDeviceDiscovery, + QObject *parent = nullptr); + + void startDiscovery(); + + /*! \brief Results available after discoveryFinished() is emitted. */ + QList results() const; + +signals: + void discoveryFinished(); + +private: + void probeAddress(const QHostAddress &address); + void finishDiscovery(); + + NetworkDeviceDiscovery *m_networkDeviceDiscovery = nullptr; + QNetworkAccessManager m_nam; + + QDateTime m_startTime; + NetworkDeviceInfos m_networkDeviceInfos; + int m_pendingProbes = 0; + QList m_results; + bool m_scanFinished = false; + bool m_finished = false; +}; + +#endif // V2CTCPDISCOVERY_H