etm-powersync-plugins-modbus/v2c/v2ctcpdiscovery.cpp
Patrick Schurig 947da7e1fc 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>
2026-06-12 13:49:22 +02:00

111 lines
3.9 KiB
C++

// 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();
}