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:
parent
d838bce331
commit
076a0dcae9
59
v2c/PORTING_STATUS.md
Normal file
59
v2c/PORTING_STATUS.md
Normal file
@ -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/<param>=<val>`) |
|
||||||
|
| 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.
|
||||||
@ -12,6 +12,8 @@
|
|||||||
#include <hardwaremanager.h>
|
#include <hardwaremanager.h>
|
||||||
#include <network/networkdevicediscovery.h>
|
#include <network/networkdevicediscovery.h>
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
|
|
||||||
IntegrationPluginV2c::IntegrationPluginV2c()
|
IntegrationPluginV2c::IntegrationPluginV2c()
|
||||||
{}
|
{}
|
||||||
|
|
||||||
@ -53,65 +55,37 @@ void IntegrationPluginV2c::setupThing(ThingSetupInfo *info)
|
|||||||
m_tcpMasters.take(thing)->deleteLater();
|
m_tcpMasters.take(thing)->deleteLater();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register or reuse the network monitor (tracks IP changes when DHCP renews).
|
// Pre-req: the charger must have a static (DHCP-reserved) IP.
|
||||||
NetworkDeviceMonitor *monitor = m_monitors.value(thing);
|
// NetworkDeviceMonitor is intentionally absent: its periodic ICMP pings
|
||||||
if (!monitor) {
|
// saturate the Trydan ESP32 WiFi stack (observed latency > 14 s, causing
|
||||||
monitor = hardwareManager()->networkDeviceDiscovery()->registerMonitor(thing);
|
// Modbus timeouts). Connectivity is derived from Modbus poll results only.
|
||||||
if (!monitor) {
|
const QHostAddress address(thing->paramValue(trydanThingAddressParamTypeId).toString());
|
||||||
info->finish(Thing::ThingErrorInvalidParameter,
|
|
||||||
QT_TR_NOOP("Could not register network monitor for this charger."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
m_monitors.insert(thing, monitor);
|
|
||||||
}
|
|
||||||
|
|
||||||
const quint16 port = static_cast<quint16>(thing->paramValue(trydanThingPortParamTypeId).toUInt());
|
const quint16 port = static_cast<quint16>(thing->paramValue(trydanThingPortParamTypeId).toUInt());
|
||||||
const quint16 slaveId = 1; // V2C Trydan always uses unit ID 1
|
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);
|
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, 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,
|
connect(master, &TrydanModbusMaster::reachableChanged, thing,
|
||||||
[this, thing, master](bool reachable) {
|
[this, thing, master](bool reachable) {
|
||||||
if (reachable) {
|
if (reachable) {
|
||||||
master->initialize();
|
master->initialize();
|
||||||
} else {
|
} 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,
|
connect(master, &TrydanModbusMaster::initializationFinished, thing,
|
||||||
[this, thing, master](bool success) {
|
[this, thing, master](bool success) {
|
||||||
if (!success) {
|
if (!success) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
thing->setStateValue(trydanConnectedStateTypeId, true);
|
thing->setStateValue(trydanConnectedStateTypeId, true);
|
||||||
|
thing->setStateValue(trydanStatusMessageStateTypeId, QString());
|
||||||
thing->setStateMinMaxValues(trydanMaxChargingCurrentStateTypeId,
|
thing->setStateMinMaxValues(trydanMaxChargingCurrentStateTypeId,
|
||||||
master->minIntensity(),
|
master->minIntensity(),
|
||||||
master->maxIntensity());
|
master->maxIntensity());
|
||||||
@ -150,7 +124,14 @@ void IntegrationPluginV2c::postSetupThing(Thing *thing)
|
|||||||
m_pluginTimer = hardwareManager()->pluginTimerManager()->registerTimer(30);
|
m_pluginTimer = hardwareManager()->pluginTimerManager()->registerTimer(30);
|
||||||
connect(m_pluginTimer, &PluginTimer::timeout, this, [this]() {
|
connect(m_pluginTimer, &PluginTimer::timeout, this, [this]() {
|
||||||
for (TrydanModbusTcpMaster *master : m_tcpMasters) {
|
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();
|
m_pluginTimer->start();
|
||||||
@ -160,10 +141,6 @@ void IntegrationPluginV2c::thingRemoved(Thing *thing)
|
|||||||
{
|
{
|
||||||
delete m_tcpMasters.take(thing);
|
delete m_tcpMasters.take(thing);
|
||||||
|
|
||||||
if (m_monitors.contains(thing)) {
|
|
||||||
hardwareManager()->networkDeviceDiscovery()->unregisterMonitor(m_monitors.take(thing));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (myThings().isEmpty() && m_pluginTimer) {
|
if (myThings().isEmpty() && m_pluginTimer) {
|
||||||
hardwareManager()->pluginTimerManager()->unregisterTimer(m_pluginTimer);
|
hardwareManager()->pluginTimerManager()->unregisterTimer(m_pluginTimer);
|
||||||
m_pluginTimer = nullptr;
|
m_pluginTimer = nullptr;
|
||||||
@ -266,6 +243,9 @@ void IntegrationPluginV2c::updateThingStates(Thing *thing, TrydanModbusMaster *m
|
|||||||
{
|
{
|
||||||
thing->setStateValue(trydanConnectedStateTypeId, master->reachable());
|
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).
|
// ChargeState: 0=A(disconnected), 1=B(connected, not charging), 2=C(charging).
|
||||||
// cf. IEC 61851 and evcc trydan.go Status().
|
// cf. IEC 61851 and evcc trydan.go Status().
|
||||||
const int cs = master->chargeState();
|
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(trydanConnectedStateTypeId, false);
|
||||||
thing->setStateValue(trydanChargingStateTypeId, false);
|
thing->setStateValue(trydanChargingStateTypeId, false);
|
||||||
thing->setStateValue(trydanPluggedInStateTypeId, false);
|
thing->setStateValue(trydanPluggedInStateTypeId, false);
|
||||||
thing->setStateValue(trydanCurrentPowerStateTypeId, 0.0);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
|
|
||||||
#include <plugintimer.h>
|
#include <plugintimer.h>
|
||||||
#include <integrations/integrationplugin.h>
|
#include <integrations/integrationplugin.h>
|
||||||
#include <network/networkdevicemonitor.h>
|
|
||||||
|
|
||||||
#include "extern-plugininfo.h"
|
#include "extern-plugininfo.h"
|
||||||
#include "trydanmodbusmaster.h"
|
#include "trydanmodbusmaster.h"
|
||||||
@ -21,6 +20,10 @@
|
|||||||
* - chargingEnabled = (PauseState==0 AND Lock==0)
|
* - chargingEnabled = (PauseState==0 AND Lock==0)
|
||||||
* - Dynamic optimizer conflict management via PauseDynamic
|
* - 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
|
* Étape 2 (RTU) will add TrydanModbusRtuMaster on the same TrydanModbusMaster
|
||||||
* interface; the logic in this file requires no changes.
|
* interface; the logic in this file requires no changes.
|
||||||
*/
|
*/
|
||||||
@ -57,12 +60,11 @@ private:
|
|||||||
*/
|
*/
|
||||||
void handleDynamicConflict(Thing *thing, TrydanModbusMaster *master);
|
void handleDynamicConflict(Thing *thing, TrydanModbusMaster *master);
|
||||||
|
|
||||||
void setDisconnectedState(Thing *thing);
|
void setDisconnectedState(Thing *thing, const QString &cause = QString());
|
||||||
|
|
||||||
PluginTimer *m_pluginTimer = nullptr;
|
PluginTimer *m_pluginTimer = nullptr;
|
||||||
|
|
||||||
QHash<Thing *, TrydanModbusTcpMaster *> m_tcpMasters;
|
QHash<Thing *, TrydanModbusTcpMaster *> m_tcpMasters;
|
||||||
QHash<Thing *, NetworkDeviceMonitor *> m_monitors;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // INTEGRATIONPLUGINV2C_H
|
#endif // INTEGRATIONPLUGINV2C_H
|
||||||
|
|||||||
@ -15,8 +15,7 @@
|
|||||||
"displayName": "Trydan",
|
"displayName": "Trydan",
|
||||||
"interfaces": [
|
"interfaces": [
|
||||||
"evcharger",
|
"evcharger",
|
||||||
"connectable",
|
"connectable"
|
||||||
"networkdevice"
|
|
||||||
],
|
],
|
||||||
"createMethods": [
|
"createMethods": [
|
||||||
"discovery",
|
"discovery",
|
||||||
@ -177,6 +176,14 @@
|
|||||||
"displayName": "Internal optimizer suspended by HEMS",
|
"displayName": "Internal optimizer suspended by HEMS",
|
||||||
"type": "bool",
|
"type": "bool",
|
||||||
"defaultValue": false
|
"defaultValue": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "c2d4e6f8-1a3b-4c7d-be0f-2a4b6c8d0e1f",
|
||||||
|
"name": "statusMessage",
|
||||||
|
"displayName": "Connection status",
|
||||||
|
"type": "QString",
|
||||||
|
"defaultValue": "",
|
||||||
|
"cached": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"actionTypes": []
|
"actionTypes": []
|
||||||
|
|||||||
@ -16,8 +16,12 @@ TrydanModbusTcpMaster::TrydanModbusTcpMaster(const QHostAddress &address,
|
|||||||
m_slaveId(slaveId)
|
m_slaveId(slaveId)
|
||||||
{
|
{
|
||||||
m_modbusTcpMaster = new ModbusTcpMaster(address, port, this);
|
m_modbusTcpMaster = new ModbusTcpMaster(address, port, this);
|
||||||
m_modbusTcpMaster->setNumberOfRetries(1);
|
// No retry per individual read: abort fast on first timeout so the 30s poll
|
||||||
m_modbusTcpMaster->setTimeout(2000);
|
// 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,
|
connect(m_modbusTcpMaster, &ModbusTcpMaster::connectionStateChanged,
|
||||||
this, &TrydanModbusTcpMaster::onConnectionStateChanged);
|
this, &TrydanModbusTcpMaster::onConnectionStateChanged);
|
||||||
|
|||||||
@ -101,7 +101,9 @@ private:
|
|||||||
bool m_updating = false;
|
bool m_updating = false;
|
||||||
bool m_initializing = 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
|
#endif // TRYDANMODBUSTCPMASTER_H
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user