From 076a0dcae9b80365a4350d58caad5a3f4e53b1e8 Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Fri, 12 Jun 2026 18:15:30 +0200 Subject: [PATCH] feat(v2c): remove NetworkDeviceMonitor, add poll-based connectivity + statusMessage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- v2c/PORTING_STATUS.md | 59 +++++++++++++++++++++++++++ v2c/integrationpluginv2c.cpp | 76 +++++++++++++++-------------------- v2c/integrationpluginv2c.h | 8 ++-- v2c/integrationpluginv2c.json | 11 ++++- v2c/trydanmodbustcpmaster.cpp | 8 +++- v2c/trydanmodbustcpmaster.h | 4 +- 6 files changed, 114 insertions(+), 52 deletions(-) create mode 100644 v2c/PORTING_STATUS.md diff --git a/v2c/PORTING_STATUS.md b/v2c/PORTING_STATUS.md new file mode 100644 index 0000000..0a8b357 --- /dev/null +++ b/v2c/PORTING_STATUS.md @@ -0,0 +1,59 @@ +## v2c / trydan — V2C Trydan (e-charger) + +**Statut : Partiel** — validé partiellement sur borne réelle, PAS encore « Supporté ». +Ne pas promettre dans la matrice beta tant que le test « VE branché » n'est pas bouclé. + +| Champ | Valeur | +|---|---| +| Plugin | `powersync-plugin-v2c` (création ETM, pas un fork) | +| Interface | `evcharger` | +| Transport validé | **Modbus TCP** (port 502, unit id 1) | +| Fallback / discovery | HTTP REST (`/RealTimeData`, `/write/=`) | +| Borne de test | V2C Trydan, IP 192.168.1.127 | +| Firmware testé | **2.4.6** | +| Date | 2026-06-12 | + +### Sources de la carte registres +- Lib officielle V2C : `github.com/V2Charge/Trydan_Modbus_TCP` (`src/v2ctrydan/modbus.py`). +- Feuille officielle « V2C - Datamanager Modbus TCP & RTU » (gid=0). +- Référence comportementale : evcc `charger/trydan.go` (HTTP) — logique enable/PauseDynamic. + +### Validé sur borne réelle (192.168.1.127, fw 2.4.6) +- **Transport Modbus TCP** : répond proprement (0 timeout) une fois le lien WiFi désaturé. +- **Décodage float Big/Big** confirmé : MaxIntensity (0x0BD2/3026) lu = 20.0. + Vérif registre brut : 3026 = 16800 = 0x41A0 → float32 0x41A00000 = 20.0. Little-endian = garbage. +- **Gestion conflit Dynamic** confirmée (cœur du plugin) : + - Dynamic=1 → toute consigne Intensity est écrasée par le PID interne (consigne 10 → relue 0). + - Écriture PauseDynamic=1 → la consigne Intensity=10 **tient** (relue 10, Dynamic=0, PauseDynamic=1). + - Écriture PauseDynamic=0 → optimiseur interne rendu au client. + - `Dynamic` observé basculant seul 0→1→0 pendant la session → **relecture à chaque poll obligatoire** (confirmé empiriquement, pas seulement à l'init). +- **ChargeState** 0/1/2 = A/B/C : cohérent (0 = déconnecté observé borne au repos). +- **SlaveError** : valait 4 le matin (VE branché), repassé à 0 borne au repos → code 4 probablement + lié à l'état de charge, pas un défaut permanent. À confirmer. + +### RESTE À VALIDER (jalon « VE branché ») +- **setMaxChargingCurrent régule la charge réelle** (pas seulement le registre). Sans VE, la borne + garde Intensity=0 même consigne acceptée → test impossible borne au repos. +- Comportement de `chargingEnabled` (PauseState/Lock en miroir) sous charge active. +- `ChargePower` / `currentPower` non-nul à confirmer pendant une charge. +- `sessionEnergy` (ChargeEnergy 0x0BC4) : NE PAS exposer tant que monotonie/reset non vérifiés + (cf. evcc issue #28047, ChargeRater retiré pour fiabilité firmware). + +### LIMITATIONS MATÉRIELLES IMPORTANTES (à documenter pour l'installation client) +1. **WiFi sature si le cloud V2C / l'app reste actif.** Latence observée : 14 000 ms et 100% perte + avec app/cloud actifs → 0 timeout possible. Après fermeture app + déconnexion cloud : 20-143 ms, + 0% perte, exploitable. **Conséquence prod : le plugin doit être le seul maître à parler à la + borne ; déconnecter la borne du cloud V2C en installation HEMS, ou timeouts généreux (≥2-3 s) + et poll espacé.** Lien radio lui-même bon (5 ms au mieux) — c'est la saturation ESP32, pas le signal. +2. **RS485 non exploitable comme transport HEMS sur ce modèle.** Le port RS485 (RJ45, pin 4=B-, + pin 5=A+) est réservé par V2C au rôle « Modbus Feeder / lecture compteur PV » pour la fonction + Dynamic (mode maître). Aucune réponse esclave obtenue sur le bus partagé (testé IDs 1/2/3 @ 9600 + et 19200, 100% timeout). Le mode « commande à distance » esclave passe par TCP, pas par le RS485 + physique. **→ Étape 2 (RTU) du plugin non applicable à cette borne.** Le RS485 reste pertinent + pour d'autres bornes du catalogue, pas pour la Trydan. + +### Notes plugin +- Clamp courant : doit lire **MaxIntensity de la borne** (= 20 ici), PAS une constante 32 en dur + (le `qBound(6, val, 32)` actuel est à corriger — la borne plafonne à 20). [À FIXER] +- Lectures : transactions individuelles de 2 registres, float Big/Big, pas de block read. +- Logique enable : `chargingEnabled = (PauseState==0 ET Lock==0)`, écritures en miroir. diff --git a/v2c/integrationpluginv2c.cpp b/v2c/integrationpluginv2c.cpp index fba02de..d169195 100644 --- a/v2c/integrationpluginv2c.cpp +++ b/v2c/integrationpluginv2c.cpp @@ -12,6 +12,8 @@ #include #include +#include + 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(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); } diff --git a/v2c/integrationpluginv2c.h b/v2c/integrationpluginv2c.h index 5ae19f9..7d79425 100644 --- a/v2c/integrationpluginv2c.h +++ b/v2c/integrationpluginv2c.h @@ -5,7 +5,6 @@ #include #include -#include #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 m_tcpMasters; - QHash m_monitors; }; #endif // INTEGRATIONPLUGINV2C_H diff --git a/v2c/integrationpluginv2c.json b/v2c/integrationpluginv2c.json index 31b510e..3901b2d 100644 --- a/v2c/integrationpluginv2c.json +++ b/v2c/integrationpluginv2c.json @@ -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": [] diff --git a/v2c/trydanmodbustcpmaster.cpp b/v2c/trydanmodbustcpmaster.cpp index 7636391..0c060af 100644 --- a/v2c/trydanmodbustcpmaster.cpp +++ b/v2c/trydanmodbustcpmaster.cpp @@ -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); diff --git a/v2c/trydanmodbustcpmaster.h b/v2c/trydanmodbustcpmaster.h index 9703799..ec735c6 100644 --- a/v2c/trydanmodbustcpmaster.h +++ b/v2c/trydanmodbustcpmaster.h @@ -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