feat(v2c): add HTTP LAN discovery (V2cTcpDiscovery)

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 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-06-12 13:49:22 +02:00
parent 14f07cc638
commit 947da7e1fc
2 changed files with 171 additions and 0 deletions

110
v2c/v2ctcpdiscovery.cpp Normal file
View File

@ -0,0 +1,110 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "v2ctcpdiscovery.h"
#include "extern-plugininfo.h"
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QJsonDocument>
#include <QJsonObject>
#include <QTimer>
#include <QTime>
#include <QUrl>
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::Result> 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<int>(ms)).toString("mm:ss.zzz")
<< "with" << m_results.count() << "V2C Trydan(s) found.";
emit discoveryFinished();
}

61
v2c/v2ctcpdiscovery.h Normal file
View File

@ -0,0 +1,61 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#ifndef V2CTCPDISCOVERY_H
#define V2CTCPDISCOVERY_H
#include <QObject>
#include <QDateTime>
#include <QNetworkAccessManager>
#include <QHash>
#include <network/networkdevicediscovery.h>
/*!
* \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<Result> 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<Result> m_results;
bool m_scanFinished = false;
bool m_finished = false;
};
#endif // V2CTCPDISCOVERY_H