// 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 namespace { /*! * \brief Courant minimal admissible sur cette borne, en ampères. * * Le plus contraignant des deux : le minimum IEC 61851 (6 A, sous lequel un véhicule ne * doit pas se voir proposer de consigne) et celui que la borne DÉCLARE dans RegMinIntensity * (0x0BD1). La borne gagne quand elle est plus exigeante ; elle ne peut pas descendre sous * la norme — une lecture aberrante à 0 ne doit pas ouvrir la voie à une consigne de 3 A. * * Le même plancher sert à l'écriture (executeAction) et à ce qui est PUBLIÉ comme minimum * du state (setStateMinMaxValues). Les deux doivent coïncider : un curseur d'app qui * propose une valeur que l'action transforme en pause serait une commande sans effet. */ double courantMinimalEffectif(TrydanModbusMaster *master) { return qMax(6.0, static_cast(master->minIntensity())); } /*! * \brief Courant maximal admissible, jamais inférieur au minimal. * * RegMaxIntensity dépend de la version de firmware et pourrait, mal lu, passer sous le * plancher. Un intervalle inversé ferait échouer setStateMinMaxValues et rendrait * l'écrêtage incohérent ; on le referme plutôt sur un point. */ double courantMaximalEffectif(TrydanModbusMaster *master) { return qMax(courantMinimalEffectif(master), static_cast(master->maxIntensity())); } } // namespace 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, courantMinimalEffectif(master), courantMaximalEffectif(master)); // 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(); // Plancher : le minimum que la BORNE déclare (RegMinIntensity), jamais sous les 6 A // de l'IEC 61851 — cf. courantMinimalEffectif(). Sous ce plancher → pause, plutôt // qu'une consigne que la borne n'honorerait pas. La borne haute lisait déjà le // registre ; la borne basse était un littéral 6.0, donc une borne qui exigerait // 8 A aurait reçu une consigne de 7 A écrite telle quelle. const double minA = courantMinimalEffectif(master); if (current < minA) { master->writePauseState(1); master->writeLock(1); thing->setStateValue(trydanPowerStateTypeId, false); info->finish(Thing::ThingErrorNoError); return; } current = qMin(current, courantMaximalEffectif(master)); 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())); // Les deux grandeurs construites AVANT chargeEnergy : les accumulateurs travaillent // sur la valeur fraîche du master, mais l'ordre garde les 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, les deux autres sont construites. Si elles divergent un // jour, on saura laquelle a menti. // // Chacune tient son propre repère (cf. SuiviDeSession::baselineTotale) : l'ordre de // ces deux appels n'a donc aucune conséquence, et ce n'est pas un hasard. accumulerEnergieDeSession(thing, master); accumulerEnergieTotale(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'incrément depuis le dernier poll réussi. La règle // des trois cas (montée / bruit / remise à zéro) vit dans incrementEnergie() — // partagée avec le totalisateur, pour qu'une correction ne s'applique jamais à // une seule des deux grandeurs. const double session = thing->stateValue(trydanSessionEnergyStateTypeId).toDouble() + incrementEnergie(brut, suivi.baseline); if (brut <= suivi.baseline - k_bruitEnergieKWh) { 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); } double IntegrationPluginV2c::incrementEnergie(double brut, double repere) { const double ecart = brut - repere; // Cas courant : le compteur de la borne monte, on le suit. if (ecart >= 0.0) return ecart; // Recul infime : bruit de lecture, pas une remise à zéro. On n'ajoute rien ; l'appelant // avance tout de même son repère sur la valeur lue, donc la remontée qui suivra ne sera // pas comptée deux fois. Conséquence assumée : le creux lui-même est recompté à la // remontée, soit un sur-comptage borné par k_bruitEnergieKWh (1 Wh) et par creux. Le // choix inverse — figer le repère — sous-compterait de la même quantité ; à ce niveau // de bruit, la simplicité du repère qui suit toujours la dernière lecture vaut mieux. if (ecart > -k_bruitEnergieKWh) return 0.0; // Remise à zéro par la borne (arrêt de charge, câble encore branché). Les incréments // d'avant la RAZ sont déjà cumulés chez l'appelant ; 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. return brut; } void IntegrationPluginV2c::accumulerEnergieTotale(Thing *thing, TrydanModbusMaster *master) { SuiviDeSession &suivi = m_sessions[thing]; const double brut = static_cast(master->chargeEnergy()); // Aucune condition de session ici, et c'est la différence de fond avec sessionEnergy : // ce compteur suit l'énergie que la BORNE a livrée, qu'un véhicule ait été vu branché // ou non. Une recharge lancée à la main sur la borne, hors pilotage — celle qu'un // journal de sessions raterait — y entre comme les autres. const double increment = incrementEnergie(brut, suivi.baselineTotale); // Premier poll après appairage : le repère vaut 0 et la borne peut déjà compter une // recharge en cours — le total démarre alors à cette valeur, pas à zéro. Ce n'est pas // une consommation inventée pour autant : le moteur se CALE sur la première valeur // sans l'ajouter à son propre diff (« Don't have a consumption counter for … Synching // internal counters to initial value », EnergyManagerImpl::updateThingPower). La // courbe ne montre donc pas de marche à l'ajout du Thing. if (increment > 0.0) { const double total = thing->stateValue(trydanTotalEnergyConsumedStateTypeId).toDouble() + increment; thing->setStateValue(trydanTotalEnergyConsumedStateTypeId, total); } suivi.baselineTotale = brut; } 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(); // Le totalisateur repart du même repère brut. Sa valeur cumulée, elle, est relue du // cache d'états — d'où "cached": true sur totalEnergyConsumed. Si la borne a remis son // compteur à zéro pendant la coupure, le premier poll verra brut < repère et ajoutera // brut : ce que la borne a compté depuis sa RAZ, pas zéro. suivi.baselineTotale = suivi.baseline; 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, totalEnergyConsumed restaure =" << thing->stateValue(trydanTotalEnergyConsumedStateTypeId).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); }