etm-powersync-plugins-modbus/v2c/integrationpluginv2c.cpp
Patrick Schurig 186f195900 v2c: connexion TCP persistante — fin des rafales SYN
Diagnostic tcpdump : le plugin ouvrait/fermait la socket Modbus TCP à
chaque cycle de poll, générant 6-7 paquets SYN consécutifs qui saturaient
l'ESP32 WiFi de la borne (timeouts en boucle). mbpoll avec une connexion
persistante = 0 % d'échec.

Corrections :
- connectDevice() : guard "si déjà connecté → no-op" (élimine le warning
  "already in ConnectedState" et les FIN/RESET parasites).
- onConnectionStateChanged(true) : appelle initialize() directement au lieu
  de setReachable(true) — évite la ré-entrée initialize() via reachableChanged.
- initialize() : setReachable(true) uniquement au succès, après lecture
  MinIntensity/MaxIntensity ; guard || m_updating ajouté.
- update() : guard || m_initializing ajouté (pas de poll concurrent avec init).
- Timer : if (!master->update()) master->connectDevice() — le socket reste
  ouvert tant que la connexion TCP est active ; connectDevice() n'est appelé
  que quand le TCP est réellement coupé.
- initializationFinished(thing) : master->update() immédiat après reconnect.
- reachableChanged handler : suppression de l'appel à initialize() (déplacé).

Résultat attendu tcpdump : 1 seul SYN à l'établissement, puis uniquement
des échanges Modbus (P. length 12/13) toutes les 30 s, aucun FIN/RESET.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 10:37:09 +02:00

368 lines
16 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
#include "integrationpluginv2c.h"
#include "v2ctcpdiscovery.h"
#include "plugininfo.h"
#include <integrations/thing.h>
#include <integrations/thingactioninfo.h>
#include <integrations/thingdescriptor.h>
#include <integrations/thingdiscoveryinfo.h>
#include <integrations/thingsetupinfo.h>
#include <hardwaremanager.h>
#include <network/networkdevicediscovery.h>
#include <QDateTime>
IntegrationPluginV2c::IntegrationPluginV2c()
{}
void IntegrationPluginV2c::discoverThings(ThingDiscoveryInfo *info)
{
V2cTcpDiscovery *discovery = new V2cTcpDiscovery(hardwareManager()->networkDeviceDiscovery(), info);
connect(discovery, &V2cTcpDiscovery::discoveryFinished, info, [this, info, discovery]() {
for (const V2cTcpDiscovery::Result &result : discovery->results()) {
ThingDescriptor descriptor(trydanThingClassId,
QStringLiteral("V2C Trydan"),
result.ipAddress);
ParamList params;
params.append(Param(trydanThingMacAddressParamTypeId,
result.networkDeviceInfo.thingParamValueMacAddress()));
params.append(Param(trydanThingHostNameParamTypeId,
result.networkDeviceInfo.thingParamValueHostName()));
params.append(Param(trydanThingAddressParamTypeId,
result.networkDeviceInfo.thingParamValueAddress()));
params.append(Param(trydanThingPortParamTypeId, 502));
descriptor.setParams(params);
if (Thing *existing = myThings().findByParams(params)) {
descriptor.setThingId(existing->id());
}
info->addThingDescriptor(descriptor);
}
info->finish(Thing::ThingErrorNoError);
});
discovery->startDiscovery();
}
void IntegrationPluginV2c::setupThing(ThingSetupInfo *info)
{
Thing *thing = info->thing();
if (m_tcpMasters.contains(thing)) {
m_tcpMasters.take(thing)->deleteLater();
}
// Pre-req: the charger must have a static (DHCP-reserved) IP.
// NetworkDeviceMonitor is intentionally absent: its periodic ICMP pings
// saturate the Trydan ESP32 WiFi stack (observed latency > 14 s, causing
// Modbus timeouts). Connectivity is derived from Modbus poll results only.
const QHostAddress address(thing->paramValue(trydanThingAddressParamTypeId).toString());
const quint16 port = static_cast<quint16>(thing->paramValue(trydanThingPortParamTypeId).toUInt());
const quint16 slaveId = 1; // V2C Trydan always uses unit ID 1
TrydanModbusTcpMaster *master = new TrydanModbusTcpMaster(address, port, slaveId, thing);
connect(info, &ThingSetupInfo::aborted, master, &TrydanModbusTcpMaster::deleteLater);
// Reachability is driven by Modbus poll results (not TCP state directly).
// initialize() is now called from TrydanModbusTcpMaster::onConnectionStateChanged(true)
// so we no longer need to call it here — this handler only updates the UI.
connect(master, &TrydanModbusMaster::reachableChanged, thing,
[this, thing](bool reachable) {
if (!reachable) {
setDisconnectedState(thing, QStringLiteral("timeout Modbus — vérifier signal WiFi de la borne"));
}
// reachable=true: UI cleared by next successful poll (updateThingStates).
});
// After init (first time or reconnect): expose current range and kick an immediate poll
// so states are refreshed without waiting up to 30 s for the next timer tick.
connect(master, &TrydanModbusMaster::initializationFinished, thing,
[this, thing, master](bool success) {
if (!success) {
return;
}
thing->setStateValue(trydanConnectedStateTypeId, true);
thing->setStateValue(trydanStatusMessageStateTypeId, QString());
thing->setStateMinMaxValues(trydanMaxChargingCurrentStateTypeId,
master->minIntensity(),
master->maxIntensity());
// Immediate first poll. For setup the info handler below also calls update();
// the m_updating guard makes the second call a no-op.
master->update();
});
connect(master, &TrydanModbusMaster::initializationFinished, info,
[this, info, thing, master](bool success) {
if (!success) {
master->deleteLater();
info->finish(Thing::ThingErrorHardwareFailure,
QT_TR_NOOP("Could not communicate with the V2C Trydan charger."));
return;
}
m_tcpMasters.insert(thing, master);
master->update();
info->finish(Thing::ThingErrorNoError);
});
// Each completed poll triggers a full state refresh + conflict handling.
connect(master, &TrydanModbusMaster::updateFinished, thing,
[this, thing, master]() {
updateThingStates(thing, master);
handleDynamicConflict(thing, master);
});
master->connectDevice();
}
void IntegrationPluginV2c::postSetupThing(Thing *thing)
{
Q_UNUSED(thing)
if (m_pluginTimer)
return;
m_pluginTimer = hardwareManager()->pluginTimerManager()->registerTimer(30);
connect(m_pluginTimer, &PluginTimer::timeout, this, [this]() {
for (TrydanModbusTcpMaster *master : m_tcpMasters) {
// update() returns false only when the TCP socket is not connected
// (or a poll/init is already running). connectDevice() is a no-op
// if the socket is already connected — it will NOT close and reopen
// the connection, avoiding the SYN bursts that saturate the ESP32 WiFi.
// Normal path: TCP stays open, only Modbus frames exchanged every 30 s.
if (!master->update()) {
master->connectDevice();
}
}
});
m_pluginTimer->start();
}
void IntegrationPluginV2c::thingRemoved(Thing *thing)
{
delete m_tcpMasters.take(thing);
if (myThings().isEmpty() && m_pluginTimer) {
hardwareManager()->pluginTimerManager()->unregisterTimer(m_pluginTimer);
m_pluginTimer = nullptr;
}
}
void IntegrationPluginV2c::executeAction(ThingActionInfo *info)
{
Thing *thing = info->thing();
TrydanModbusTcpMaster *master = m_tcpMasters.value(thing);
if (!master || !master->reachable()) {
info->finish(Thing::ThingErrorHardwareNotAvailable,
QT_TR_NOOP("The V2C Trydan charger is not reachable."));
return;
}
if (info->action().actionTypeId() == trydanPowerActionTypeId) {
const bool enable = info->action().paramValue(trydanPowerActionPowerParamTypeId).toBool();
// setChargingEnabled — always mirror PauseState and Lock together.
// cf. evcc trydan.go Enable(): both registers must be written consistently.
// PauseState: 1=paused(disabled) / 0=active(enabled)
// Lock: 1=locked(disabled) / 0=unlocked(enabled)
const quint16 pauseVal = enable ? 0 : 1;
const quint16 lockVal = enable ? 0 : 1;
// writeCompleted carries the originating register address so we can ignore
// concurrent background writes (e.g. PauseDynamic from conflict management).
master->writePauseState(pauseVal);
connect(master, &TrydanModbusMaster::writeCompleted, info,
[this, info, thing, master, lockVal, enable]
(quint16 addr, bool ok) {
if (addr != TrydanModbusMaster::WRegPauseState) return; // ignore others
disconnect(master, &TrydanModbusMaster::writeCompleted, info, nullptr);
if (!ok) { info->finish(Thing::ThingErrorHardwareFailure); return; }
master->writeLock(lockVal);
connect(master, &TrydanModbusMaster::writeCompleted, info,
[this, info, thing, master, enable](quint16 addr2, bool ok2) {
if (addr2 != TrydanModbusMaster::WRegLock) return; // ignore others
disconnect(master, &TrydanModbusMaster::writeCompleted, info, nullptr);
if (ok2) {
thing->setStateValue(trydanPowerStateTypeId, enable);
// Fire-and-forget: apply PauseDynamic conflict suppression.
// Does not block the action — next poll will reflect the result.
if (master->dynamicMode() == 1) {
const bool suspend = thing->setting(
trydanSettingsSuspendInternalOptimizerParamTypeId).toBool();
if (suspend)
master->writePauseDynamic(enable ? 1 : 0);
}
master->update();
info->finish(Thing::ThingErrorNoError);
} else {
info->finish(Thing::ThingErrorHardwareFailure);
}
});
});
return;
}
if (info->action().actionTypeId() == trydanMaxChargingCurrentActionTypeId) {
double current = info->action().paramValue(
trydanMaxChargingCurrentActionMaxChargingCurrentParamTypeId).toDouble();
// Clamp to IEC 61851 minimum. Below 6 A → pause rather than send invalid setpoint.
if (current < 6.0) {
master->writePauseState(1);
master->writeLock(1);
thing->setStateValue(trydanPowerStateTypeId, false);
info->finish(Thing::ThingErrorNoError);
return;
}
current = qMin(current, static_cast<double>(master->maxIntensity()));
const quint16 amps = static_cast<quint16>(qRound(current));
master->writeIntensity(amps);
connect(master, &TrydanModbusMaster::writeCompleted, info,
[this, info, thing, master, current](quint16 addr, bool ok) {
if (addr != TrydanModbusMaster::WRegIntensity) return;
disconnect(master, &TrydanModbusMaster::writeCompleted, info, nullptr);
if (ok) {
thing->setStateValue(trydanMaxChargingCurrentStateTypeId, current);
master->update();
info->finish(Thing::ThingErrorNoError);
} else {
info->finish(Thing::ThingErrorHardwareFailure);
}
});
return;
}
info->finish(Thing::ThingErrorUnsupportedFeature);
}
// --- Private ---
/*!
* SlaveError register (0x0BC5) firmware table, confirmed on Trydan fw2.4.6.
* Returns empty string for code 0 (no error).
* Code 4 also triggers a statusMessage update in updateThingStates().
*/
static QString decodeSlaveError(int code)
{
switch (code) {
case 0: return QString();
case 1: return QStringLiteral("Erreur communication Modbus");
case 2: return QStringLiteral("Erreur lecture registre");
case 3: return QStringLiteral("Erreur esclave");
case 4: return QStringLiteral("Borne en reconnexion WiFi");
case 5: return QStringLiteral("Attente communication");
case 6: return QStringLiteral("Mauvaise IP");
case 7: return QStringLiteral("Esclave introuvable");
case 8: return QStringLiteral("Mauvais esclave");
case 9: return QStringLiteral("Pas de réponse");
case 10: return QStringLiteral("Pince non connectée");
default: return QStringLiteral("Code erreur inconnu (%1)").arg(code);
}
}
void IntegrationPluginV2c::updateThingStates(Thing *thing, TrydanModbusMaster *master)
{
thing->setStateValue(trydanConnectedStateTypeId, master->reachable());
// Poll succeeded → clear any previous offline message.
thing->setStateValue(trydanStatusMessageStateTypeId, QString());
// ChargeState: 0=A(disconnected), 1=B(connected, not charging), 2=C(charging).
// cf. IEC 61851 and evcc trydan.go Status().
const int cs = master->chargeState();
thing->setStateValue(trydanPluggedInStateTypeId, cs >= 1);
thing->setStateValue(trydanChargingStateTypeId, cs == 2);
// chargingEnabled = PauseState==0 AND Lock==0 (cf. evcc trydan.go Enabled()).
thing->setStateValue(trydanPowerStateTypeId,
master->pauseState() == 0 && master->lock() == 0);
thing->setStateValue(trydanCurrentPowerStateTypeId, static_cast<double>(master->chargePower()));
thing->setStateValue(trydanHousePowerStateTypeId, static_cast<double>(master->housePower()));
thing->setStateValue(trydanSolarPowerStateTypeId, static_cast<double>(master->powerFV()));
thing->setStateValue(trydanChargeEnergyStateTypeId, static_cast<double>(master->chargeEnergy()));
thing->setStateValue(trydanIntensityStateTypeId, static_cast<double>(master->intensity()));
thing->setStateValue(trydanPowerL1StateTypeId, static_cast<double>(master->powerL1()));
thing->setStateValue(trydanPowerL2StateTypeId, static_cast<double>(master->powerL2()));
thing->setStateValue(trydanPowerL3StateTypeId, static_cast<double>(master->powerL3()));
const int slaveErr = master->slaveError();
thing->setStateValue(trydanSlaveErrorStateTypeId, static_cast<uint>(slaveErr));
const QString errMsg = decodeSlaveError(slaveErr);
thing->setStateValue(trydanSlaveErrorMessageStateTypeId, errMsg);
// Code 4: borne drops WiFi mid-session and reconnects — surface it prominently.
if (slaveErr == 4) {
thing->setStateValue(trydanStatusMessageStateTypeId,
QStringLiteral("Borne en reconnexion WiFi"));
}
// Dynamic==1 means the V2C internal PV optimizer is configured and running.
const bool conflict = (master->dynamicMode() == 1);
thing->setStateValue(trydanConflictDetectedStateTypeId, conflict);
thing->setStateValue(trydanOptimizerSuspendedStateTypeId, master->pauseDynamic() == 1);
if (conflict) {
qCDebug(dcV2C()) << thing->name()
<< ": V2C internal optimizer is active (Dynamic==1). "
"HEMS will suppress it via PauseDynamic if suspendInternalOptimizer==true.";
}
}
void IntegrationPluginV2c::handleDynamicConflict(Thing *thing, TrydanModbusMaster *master)
{
// The V2C Trydan embeds its own solar-tracking PID ("Dynamic mode").
// Two controllers must not drive the same charger simultaneously.
//
// Correct suppression: write PauseDynamic=1 to freeze the internal PID.
// WRONG approach: write Dynamic=0 — this silences ChargePower telemetry.
// cf. evcc charger/trydan.go and github.com/evcc-io/evcc/issues/28047.
//
// PauseDynamic is only relevant when Dynamic==1 (optimizer configured on device).
// We re-evaluate every poll because the V2C app can toggle Dynamic at any time.
if (master->dynamicMode() != 1)
return; // No internal optimizer — nothing to manage.
const bool suspendAllowed = thing->setting(trydanSettingsSuspendInternalOptimizerParamTypeId).toBool();
if (!suspendAllowed) {
qCWarning(dcV2C()) << thing->name()
<< ": V2C internal optimizer active but suspendInternalOptimizer==false. "
"HEMS and V2C PID may fight — expect oscillations.";
return;
}
// HEMS is in control when chargingEnabled is true.
const bool hemsInControl = (master->pauseState() == 0 && master->lock() == 0);
const int desired = hemsInControl ? 1 : 0;
// Only write on transition to avoid unnecessary flash wear.
if (master->pauseDynamic() != desired) {
qCDebug(dcV2C()) << thing->name()
<< ": writing PauseDynamic =" << desired
<< (hemsInControl ? "(HEMS taking control)" : "(releasing to V2C PID)");
master->writePauseDynamic(static_cast<quint16>(desired));
}
}
void IntegrationPluginV2c::setDisconnectedState(Thing *thing, const QString &cause)
{
thing->setStateValue(trydanConnectedStateTypeId, false);
thing->setStateValue(trydanChargingStateTypeId, false);
thing->setStateValue(trydanPluggedInStateTypeId, false);
thing->setStateValue(trydanCurrentPowerStateTypeId, 0.0);
// Inform the user with a timestamp so they know when the outage started,
// and a hint pointing to the most likely cause (WiFi signal on this charger).
const QString ts = QDateTime::currentDateTime().toString(QStringLiteral("dd/MM HH:mm"));
const QString msg = cause.isEmpty()
? QStringLiteral("Hors ligne depuis ") + ts
: QStringLiteral("Hors ligne depuis ") + ts + QStringLiteral("") + cause;
thing->setStateValue(trydanStatusMessageStateTypeId, msg);
}