// SPDX-License-Identifier: GPL-3.0-or-later #include "integrationpluginv2c.h" #include "v2ctcpdiscovery.h" #include "plugininfo.h" #include #include #include #include #include #include #include #include #include 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(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); // --- Nombre de phases : SAISI, parce que la borne ne sait pas le dire ------------------- // // Il n'existe aucun registre de phases dans la carte Modbus V2C (0x0BC2-0x0BD5, // 0x177A-0x1783) : le câblage mono/tri n'est pas lisible sur la borne. Or le moteur // energy-etm en a besoin, et son repli est SILENCIEUX — EvCharger::phaseCount() retourne 1 // quand l'état est absent. Sur une Trydan triphasée, cela publierait un plancher de // 6 A x 230 V x 1 = 1 380 W au lieu de 4 140, et convertirait les watts alloués en un // courant trois fois trop élevé. La borne démarrerait, au mauvais courant, sans erreur. // // Le nombre de phases vient donc d'un RÉGLAGE, modifiable si la borne est recâblée, et il // est recopié dans l'état que le moteur lit. Défaut 1 : le même que le repli du moteur, // pour qu'une installation non renseignée se comporte comme avant ce correctif. auto appliquerNombreDePhases = [](Thing *t) { const uint phases = t->setting(trydanSettingsPhaseCountParamTypeId).toUInt(); t->setStateValue(trydanPhaseCountStateTypeId, (phases == 3 ? 3u : 1u)); }; appliquerNombreDePhases(thing); connect(thing, &Thing::settingChanged, thing, [thing, appliquerNombreDePhases]( const ParamTypeId ¶mTypeId, const QVariant &/*value*/) { if (paramTypeId == trydanSettingsPhaseCountParamTypeId) { appliquerNombreDePhases(thing); } }); // Reprendre le suivi de session AVANT que le premier poll n'arrive : c'est ici, // et pas au premier poll, que le cache d'états de nymea est encore intact // (initThing() restaure les états cachés avant setupThing()). restaurerSuiviDeSession(thing); // Setup raté : le Thing est détruit par le moteur, et m_sessions garderait une // clé pendante. Elle n'est jamais déréférencée, mais un Thing réalloué à la même // adresse hériterait d'un repère qui n'est pas le sien — et d'un sessionEnergy // qui saute. Notre handler est connecté avant celui du moteur, donc il passe // avant la destruction. connect(info, &ThingSetupInfo::finished, this, [this, info]() { if (info->status() != Thing::ThingErrorNoError) { m_sessions.remove(info->thing()); } }); // 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); m_sessions.remove(thing); // Le drapeau de session persisté n'a plus de propriétaire : le laisser traîner // ferait repartir une borne réappairée sur une session qui n'existe plus. QSettings *stockage = pluginStorage(); stockage->remove(thing->id().toString()); 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(master->maxIntensity())); const quint16 amps = static_cast(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(master->chargePower())); thing->setStateValue(trydanHousePowerStateTypeId, static_cast(master->housePower())); thing->setStateValue(trydanSolarPowerStateTypeId, static_cast(master->powerFV())); thing->setStateValue(trydanIntensityStateTypeId, static_cast(master->intensity())); // currentL1/L2/L3 : ces registres sont intitulés « Power_Lx » par V2C mais portent // des ampères (cf. TrydanModbusMaster::RegPower_L1). Publiés en A, sous un nom de // courant : une unité qui ment coûte plus cher qu'un state manquant. thing->setStateValue(trydanCurrentL1StateTypeId, static_cast(master->currentL1())); thing->setStateValue(trydanCurrentL2StateTypeId, static_cast(master->currentL2())); thing->setStateValue(trydanCurrentL3StateTypeId, static_cast(master->currentL3())); // sessionEnergy AVANT chargeEnergy : l'accumulateur travaille sur la valeur // fraîche du master, mais l'ordre garde les deux states cohérents pour qui les // lit à la volée. chargeEnergy reste publié sous son nom exact, à côté : c'est la // mesure brute de la borne, sessionEnergy la grandeur construite. Si les deux // divergent un jour, on saura laquelle a menti. accumulerEnergieDeSession(thing, master); thing->setStateValue(trydanChargeEnergyStateTypeId, static_cast(master->chargeEnergy())); const int slaveErr = master->slaveError(); thing->setStateValue(trydanSlaveErrorStateTypeId, static_cast(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(desired)); } } void IntegrationPluginV2c::accumulerEnergieDeSession(Thing *thing, TrydanModbusMaster *master) { SuiviDeSession &suivi = m_sessions[thing]; const double brut = static_cast(master->chargeEnergy()); const bool branche = (master->chargeState() >= 1); // 1. Confirmation du débranchement. Seuls les polls RÉUSSIS parviennent ici : // updateFinished() n'est émis qu'après une séquence de lecture complète. Une // coupure réseau gèle donc le compteur d'absences, elle ne le fait pas avancer. // // ┌─ CETTE PROPRIÉTÉ PORTE. Ne pas la « corriger » en avançant le compteur sur les // │ polls échoués, ni en le pilotant depuis un minuteur. // │ // │ Elle rend un faux débranchement IMPOSSIBLE à fabriquer par un lien mort — ce qui // │ n'est pas théorique ici : la campagne du 2026-08-28 a mesuré jusqu'à 70 % de polls // │ perdus, et le 2026-09-06 encore 40 % sur l'heure. Un compteur qui avancerait au // │ timeout clôturerait une session toutes les 90 s sur ce lien. // │ // │ Et elle devient LOAD-BEARING au-delà de la session : les dérogations (« boost ») // │ se terminent au débranchement, et une dérogation terminée à tort par un lien qui // │ bat serait une charge interrompue sans raison. Cf. docs/DESIGN_DEROGATION.md du // │ dépôt moteur — l'asymétrie qu'il demande existait déjà ici. // └─ suivi.absences = branche ? 0 : suivi.absences + 1; // 2. Clôture, une fois le débranchement confirmé sur k_absencesPourClore polls. // On ne remet PAS sessionEnergy à zéro ici : la question « combien la nuit // a-t-elle livré ? » se pose au matin, voiture déjà partie. La valeur reste // affichée jusqu'au branchement suivant, qui seul ouvre une nouvelle session. if (suivi.ouverte && suivi.absences >= k_absencesPourClore) { suivi.ouverte = false; ecrireSessionOuverte(thing, false); qCDebug(dcV2C()) << thing->name() << ": session close a" << thing->stateValue(trydanSessionEnergyStateTypeId).toDouble() << "kWh (debranchement confirme sur" << k_absencesPourClore << "polls)"; } // 3. Ouverture : un véhicule vient d'être branché. On part de la valeur brute et // non de zéro — si le branchement a eu lieu pendant une coupure réseau, la // borne a déjà compté, et cette énergie appartient à la session qui s'ouvre. if (!suivi.ouverte && branche) { suivi.ouverte = true; suivi.baseline = brut; thing->setStateValue(trydanSessionEnergyStateTypeId, brut); ecrireSessionOuverte(thing, true); qCDebug(dcV2C()) << thing->name() << ": nouvelle session, depart a" << brut << "kWh"; return; } // 4. Session close et véhicule absent : rien à accumuler. On suit quand même la // valeur brute pour que la prochaine ouverture parte du bon repère. if (!suivi.ouverte) { suivi.baseline = brut; return; } // 5. Session ouverte : accumuler l'écart depuis le dernier poll réussi. double session = thing->stateValue(trydanSessionEnergyStateTypeId).toDouble(); const double ecart = brut - suivi.baseline; if (ecart >= 0.0) { // Cas courant : le compteur de la borne monte, on le suit. session += ecart; } else if (ecart > -k_bruitEnergieKWh) { // Recul infime : bruit de lecture, pas une remise à zéro. On n'ajoute rien ; // la baseline suit tout de même la valeur lue, donc la remontée qui suivra // ne sera pas comptée deux fois. } else { // Remise à zéro par la borne (arrêt de charge, câble encore branché). Les // incréments d'avant la RAZ sont déjà dans session ; ce qui reste à ajouter // est ce que la borne a compté DEPUIS. Perte résiduelle : l'énergie livrée // entre le dernier poll et l'arrêt, bornée par un intervalle de poll — c'est // exactement pourquoi ce calcul vit dans le plugin (30 s) et pas dans le // moteur (à la minute), qui raterait des cycles entiers. session += brut; qCDebug(dcV2C()) << thing->name() << ": chargeEnergy remis a zero par la borne (" << suivi.baseline << "->" << brut << "kWh), session cumulee" << session << "kWh"; } suivi.baseline = brut; thing->setStateValue(trydanSessionEnergyStateTypeId, session); } void IntegrationPluginV2c::restaurerSuiviDeSession(Thing *thing) { SuiviDeSession suivi; // Repère de reprise = la dernière valeur BRUTE publiée avant l'arrêt de nymead. // Elle est relue du cache d'états : d'où "cached": true sur chargeEnergy dans // integrationpluginv2c.json. NE PAS retirer cette propriété — l'accumulateur en // dépend, et sans elle une recharge qui traverse un redémarrage repartirait de // zéro sans que rien ne le signale. suivi.baseline = thing->stateValue(trydanChargeEnergyStateTypeId).toDouble(); QSettings *stockage = pluginStorage(); stockage->beginGroup(thing->id().toString()); suivi.ouverte = stockage->value("sessionOuverte", false).toBool(); stockage->endGroup(); m_sessions.insert(thing, suivi); // Ce que fera le premier poll, selon l'état retrouvé : // - session ouverte + véhicule toujours branché → l'écart accumulé pendant la // coupure est repris (cas du redémarrage de nymead en pleine recharge) ; // - session ouverte + véhicule absent → clôture après confirmation, la valeur // restaurée reste lisible ; // - session close + véhicule branché → nouvelle session, remise à zéro. // LIMITE ASSUMÉE : si la voiture a été débranchée PUIS rebranchée pendant la // coupure, on ne peut pas le savoir — la borne ne numérote pas ses sessions — et // la nouvelle session héritera du total de la précédente. Le choix inverse // (repartir de zéro à chaque redémarrage) casserait justement le cas que ce // correctif existe pour couvrir : la recharge de nuit relue au matin. qCDebug(dcV2C()) << thing->name() << ": reprise du suivi de session — ouverte =" << suivi.ouverte << ", repere chargeEnergy =" << suivi.baseline << "kWh, sessionEnergy restauree =" << thing->stateValue(trydanSessionEnergyStateTypeId).toDouble() << "kWh"; } void IntegrationPluginV2c::ecrireSessionOuverte(Thing *thing, bool ouverte) { // Écrit sur transition seulement (ouverture / clôture), jamais à chaque poll : // le stockage est sur la mémoire flash de la passerelle. QSettings *stockage = pluginStorage(); stockage->beginGroup(thing->id().toString()); stockage->setValue("sessionOuverte", ouverte); stockage->endGroup(); } 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); }