The Trydan ESP32 WiFi stack saturates under nymea's periodic ICMP pings
(observed: latency > 14 s, Modbus timeouts, charger drops off).
Changes:
- NetworkDeviceMonitor removed entirely from setupThing() and thingRemoved().
The charger is expected to have a DHCP-reserved IP (documented in code).
Address comes from trydanThingAddressParamTypeId param — already the correct
path after the previous setup fix.
- Connectivity now derives from Modbus poll success/failure only (reachable()
from TrydanModbusTcpMaster), NOT from network ping. A ping response does
not imply Modbus usability on this hardware.
- postSetupThing() timer (30 s): if not reachable, calls connectDevice() for
reconnect; if reachable, calls update(). The 30 s period IS the backoff —
no aggressive retry loop.
- Modbus timeout: 2 s → 5 s (tolerates residual WiFi latency spikes).
- Modbus retries per read: 1 → 0 (abort fast on first timeout; full poll
sequence already aborts at first error via doNextRead).
- k_errorLimit: 5 → 3 (3 × 5 s = 15 s before marking unreachable).
- New state "statusMessage" (QString): set on disconnect with timestamp +
cause ("timeout Modbus — vérifier signal WiFi de la borne"); cleared on
successful poll. Visible in nymea-app; helps SAV without SSH access.
- "networkdevice" removed from JSON interfaces (contract broken without monitor).
Invariants preserved: Big/Big float32 decode, no block read, PauseState+Lock
mirror, PauseDynamic conflict management, writeCompleted address filtering.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
329 lines
14 KiB
C++
329 lines
14 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);
|
|
|
|
// Reachable → initialize (reads MinIntensity, MaxIntensity).
|
|
// Lost → set disconnected state with timestamp for SAV visibility.
|
|
connect(master, &TrydanModbusMaster::reachableChanged, thing,
|
|
[this, thing, master](bool reachable) {
|
|
if (reachable) {
|
|
master->initialize();
|
|
} else {
|
|
setDisconnectedState(thing, QStringLiteral("timeout Modbus — vérifier signal WiFi de la borne"));
|
|
}
|
|
});
|
|
|
|
// After init, expose the configured current range and finish setup.
|
|
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());
|
|
});
|
|
|
|
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) {
|
|
if (master->reachable()) {
|
|
master->update();
|
|
} else {
|
|
// TCP connection may have dropped (charger WiFi glitch).
|
|
// connectDevice() is idempotent on an already-connecting socket;
|
|
// the 30s timer provides the reconnect backoff — no busy-loop.
|
|
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 ---
|
|
|
|
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(trydanSlaveErrorStateTypeId, static_cast<uint>(master->slaveError()));
|
|
thing->setStateValue(trydanChargeEnergyStateTypeId, static_cast<double>(master->chargeEnergy()));
|
|
thing->setStateValue(trydanIntensityStateTypeId, static_cast<double>(master->intensity()));
|
|
|
|
// 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);
|
|
}
|