feat(v2c): remove NetworkDeviceMonitor, add poll-based connectivity + statusMessage

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>
This commit is contained in:
Patrick Schurig 2026-06-12 18:15:30 +02:00
parent 07dca8f1d2
commit 9f0743358a
5 changed files with 55 additions and 52 deletions

View File

@ -12,6 +12,8 @@
#include <hardwaremanager.h>
#include <network/networkdevicediscovery.h>
#include <QDateTime>
IntegrationPluginV2c::IntegrationPluginV2c()
{}
@ -53,65 +55,37 @@ void IntegrationPluginV2c::setupThing(ThingSetupInfo *info)
m_tcpMasters.take(thing)->deleteLater();
}
// Register or reuse the network monitor (tracks IP changes when DHCP renews).
NetworkDeviceMonitor *monitor = m_monitors.value(thing);
if (!monitor) {
monitor = hardwareManager()->networkDeviceDiscovery()->registerMonitor(thing);
if (!monitor) {
info->finish(Thing::ThingErrorInvalidParameter,
QT_TR_NOOP("Could not register network monitor for this charger."));
return;
}
m_monitors.insert(thing, monitor);
}
// 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
// Prefer the monitor's cached address (ARP/mDNS already resolved) but fall back
// to the stored address param. The monitor has a null address at first
// registerMonitor() when the device was added by IP (createMethod "user") or when
// nymead restarts before the monitor's ARP scan completes — connecting to a null
// QHostAddress would produce "device ':502'" and fail immediately.
QHostAddress address = monitor->networkDeviceInfo().address();
if (address.isNull())
address = QHostAddress(thing->paramValue(trydanThingAddressParamTypeId).toString());
TrydanModbusTcpMaster *master = new TrydanModbusTcpMaster(address, port, slaveId, thing);
// Update target IP when DHCP renews. Guard against transient null info that
// the monitor can emit during its own internal refresh cycles.
connect(monitor, &NetworkDeviceMonitor::networkDeviceInfoChanged,
master, [master](const NetworkDeviceInfo &info) {
if (!info.address().isNull())
master->setHostAddress(info.address());
});
// Abort the setup if the info object is destroyed before we finish.
connect(info, &ThingSetupInfo::aborted, master, &TrydanModbusTcpMaster::deleteLater);
connect(info, &ThingSetupInfo::aborted, monitor, [this, thing]() {
if (m_monitors.contains(thing)) {
hardwareManager()->networkDeviceDiscovery()->unregisterMonitor(m_monitors.take(thing));
}
});
// Reachable → initialize; lost → disconnected state.
// 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);
setDisconnectedState(thing, QStringLiteral("timeout Modbus — vérifier signal WiFi de la borne"));
}
});
// After init, set the current-range and report setup done.
// 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());
@ -150,7 +124,14 @@ void IntegrationPluginV2c::postSetupThing(Thing *thing)
m_pluginTimer = hardwareManager()->pluginTimerManager()->registerTimer(30);
connect(m_pluginTimer, &PluginTimer::timeout, this, [this]() {
for (TrydanModbusTcpMaster *master : m_tcpMasters) {
master->update();
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();
@ -160,10 +141,6 @@ void IntegrationPluginV2c::thingRemoved(Thing *thing)
{
delete m_tcpMasters.take(thing);
if (m_monitors.contains(thing)) {
hardwareManager()->networkDeviceDiscovery()->unregisterMonitor(m_monitors.take(thing));
}
if (myThings().isEmpty() && m_pluginTimer) {
hardwareManager()->pluginTimerManager()->unregisterTimer(m_pluginTimer);
m_pluginTimer = nullptr;
@ -266,6 +243,9 @@ void IntegrationPluginV2c::updateThingStates(Thing *thing, TrydanModbusMaster *m
{
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();
@ -331,10 +311,18 @@ void IntegrationPluginV2c::handleDynamicConflict(Thing *thing, TrydanModbusMaste
}
}
void IntegrationPluginV2c::setDisconnectedState(Thing *thing)
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);
}

View File

@ -5,7 +5,6 @@
#include <plugintimer.h>
#include <integrations/integrationplugin.h>
#include <network/networkdevicemonitor.h>
#include "extern-plugininfo.h"
#include "trydanmodbusmaster.h"
@ -21,6 +20,10 @@
* - chargingEnabled = (PauseState==0 AND Lock==0)
* - Dynamic optimizer conflict management via PauseDynamic
*
* No NetworkDeviceMonitor: the Trydan ESP32 WiFi stack saturates under
* periodic ICMP pings (latency > 14 s, Modbus timeouts). Connectivity is
* derived from Modbus poll success/failure instead.
*
* Étape 2 (RTU) will add TrydanModbusRtuMaster on the same TrydanModbusMaster
* interface; the logic in this file requires no changes.
*/
@ -57,12 +60,11 @@ private:
*/
void handleDynamicConflict(Thing *thing, TrydanModbusMaster *master);
void setDisconnectedState(Thing *thing);
void setDisconnectedState(Thing *thing, const QString &cause = QString());
PluginTimer *m_pluginTimer = nullptr;
QHash<Thing *, TrydanModbusTcpMaster *> m_tcpMasters;
QHash<Thing *, NetworkDeviceMonitor *> m_monitors;
};
#endif // INTEGRATIONPLUGINV2C_H

View File

@ -15,8 +15,7 @@
"displayName": "Trydan",
"interfaces": [
"evcharger",
"connectable",
"networkdevice"
"connectable"
],
"createMethods": [
"discovery",
@ -177,6 +176,14 @@
"displayName": "Internal optimizer suspended by HEMS",
"type": "bool",
"defaultValue": false
},
{
"id": "c2d4e6f8-1a3b-4c7d-be0f-2a4b6c8d0e1f",
"name": "statusMessage",
"displayName": "Connection status",
"type": "QString",
"defaultValue": "",
"cached": false
}
],
"actionTypes": []

View File

@ -16,8 +16,12 @@ TrydanModbusTcpMaster::TrydanModbusTcpMaster(const QHostAddress &address,
m_slaveId(slaveId)
{
m_modbusTcpMaster = new ModbusTcpMaster(address, port, this);
m_modbusTcpMaster->setNumberOfRetries(1);
m_modbusTcpMaster->setTimeout(2000);
// No retry per individual read: abort fast on first timeout so the 30s poll
// cycle is not blocked for minutes on a dropped connection.
m_modbusTcpMaster->setNumberOfRetries(0);
// 5 s tolerates the WiFi latency spikes observed on the Trydan ESP32
// without waiting forever when the charger is genuinely unreachable.
m_modbusTcpMaster->setTimeout(5000);
connect(m_modbusTcpMaster, &ModbusTcpMaster::connectionStateChanged,
this, &TrydanModbusTcpMaster::onConnectionStateChanged);

View File

@ -101,7 +101,9 @@ private:
bool m_updating = false;
bool m_initializing = false;
static constexpr int k_errorLimit = 5;
// 3 consecutive failures → mark unreachable. With a 5 s timeout and no
// retry, 3 failed polls abort within 15 s — acceptable for HEMS decisions.
static constexpr int k_errorLimit = 3;
};
#endif // TRYDANMODBUSTCPMASTER_H