diff --git a/v2c/integrationpluginv2c.cpp b/v2c/integrationpluginv2c.cpp index 735efdd..3d2e2e5 100644 --- a/v2c/integrationpluginv2c.cpp +++ b/v2c/integrationpluginv2c.cpp @@ -13,6 +13,7 @@ #include #include +#include IntegrationPluginV2c::IntegrationPluginV2c() {} @@ -91,6 +92,22 @@ void IntegrationPluginV2c::setupThing(ThingSetupInfo *info) } }); + // 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. @@ -168,6 +185,12 @@ void IntegrationPluginV2c::postSetupThing(Thing *thing) 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); @@ -310,11 +333,22 @@ void IntegrationPluginV2c::updateThingStates(Thing *thing, TrydanModbusMaster *m thing->setStateValue(trydanCurrentPowerStateTypeId, static_cast(master->chargePower())); thing->setStateValue(trydanHousePowerStateTypeId, static_cast(master->housePower())); thing->setStateValue(trydanSolarPowerStateTypeId, static_cast(master->powerFV())); - thing->setStateValue(trydanChargeEnergyStateTypeId, static_cast(master->chargeEnergy())); thing->setStateValue(trydanIntensityStateTypeId, static_cast(master->intensity())); - thing->setStateValue(trydanPowerL1StateTypeId, static_cast(master->powerL1())); - thing->setStateValue(trydanPowerL2StateTypeId, static_cast(master->powerL2())); - thing->setStateValue(trydanPowerL3StateTypeId, static_cast(master->powerL3())); + + // 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)); @@ -374,6 +408,122 @@ void IntegrationPluginV2c::handleDynamicConflict(Thing *thing, TrydanModbusMaste } } +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. + 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); diff --git a/v2c/integrationpluginv2c.h b/v2c/integrationpluginv2c.h index 7d79425..61c7c61 100644 --- a/v2c/integrationpluginv2c.h +++ b/v2c/integrationpluginv2c.h @@ -60,11 +60,69 @@ private: */ void handleDynamicConflict(Thing *thing, TrydanModbusMaster *master); + /*! + * \brief Construire sessionEnergy en accumulant les incréments de chargeEnergy. + * + * La Trydan ne publie pas d'énergie de session. Son compteur chargeEnergy + * (0x0BC4) est remis à zéro à chaque ARRÊT de charge, câble encore branché — + * mesuré le 2026-08-28 : arrêt à t+1 s, remise à zéro à t+8 s, débranchement + * seulement à t+127 s. Sur du pilotage par surplus, c'est notre propre arbitrage + * qui coupe au passage d'un nuage : le HEMS effacerait sa propre mesure plusieurs + * fois par jour, et une recharge de nuit relue au matin vaudrait 0. + * + * L'accumulation se fait ici, dans le plugin, et pas dans le moteur : le plugin + * scrute la borne toutes les 30 s, le moteur à la minute — un incrément effacé + * entre deux lectures du moteur lui serait invisible. En sortie, le moteur et + * l'app reçoivent le contrat evcharger standard, sans cas particulier V2C en aval. + * + * \sa m_sessions pour la machine à états, et AGENTS.md § SESSION ENERGY. + */ + void accumulerEnergieDeSession(Thing *thing, TrydanModbusMaster *master); + + /*! \brief Restaurer l'état de session persisté (appelé depuis setupThing). */ + void restaurerSuiviDeSession(Thing *thing); + /*! \brief Persister « une session est ouverte » (écrit sur transition seulement). */ + void ecrireSessionOuverte(Thing *thing, bool ouverte); + void setDisconnectedState(Thing *thing, const QString &cause = QString()); + /*! + * \brief Suivi par borne de la session de recharge en cours. + * + * \c baseline est la dernière valeur brute de chargeEnergy vue par un poll + * RÉUSSI ; les polls en échec n'émettent pas updateFinished() et ne touchent + * donc à rien — une coupure WiFi gèle le compteur, elle ne l'efface pas. + */ + struct SuiviDeSession { + double baseline = 0.0; ///< dernière valeur brute de chargeEnergy (kWh) + bool ouverte = false; ///< un véhicule est branché : session en cours + int absences = 0; ///< polls réussis consécutifs lus « débranché » + }; + + /*! + * \brief Polls « débranché » consécutifs exigés avant de clore une session. + * + * Le lien WiFi de la borne est instable (2 à 4 s de latence, 40 % de perte + * observés le 2026-08-28 avant stabilisation) : une seule lecture aberrante à + * ChargeState=0 effacerait la session. 3 polls x 30 s = 90 s de confirmation. + * Marge vérifiée sur le cycle mesuré : débranchement réel à t+127 s, session + * suivante à t+278 s — la fenêtre de 90 s tient entre les deux. + */ + static const int k_absencesPourClore = 3; + + /*! + * \brief Seuil sous lequel une baisse de chargeEnergy est du bruit, pas une RAZ. + * + * Le compteur firmware monte de façon monotone puis retombe à 0 ; il ne recule + * pas de quelques Wh. 1 Wh est très au-dessus de la précision d'un float32 aux + * ordres de grandeur en jeu, et très en dessous d'une vraie remise à zéro. + */ + static constexpr double k_bruitEnergieKWh = 0.001; + PluginTimer *m_pluginTimer = nullptr; QHash m_tcpMasters; + QHash m_sessions; }; #endif // INTEGRATIONPLUGINV2C_H diff --git a/v2c/integrationpluginv2c.json b/v2c/integrationpluginv2c.json index b8bf700..ca1695d 100644 --- a/v2c/integrationpluginv2c.json +++ b/v2c/integrationpluginv2c.json @@ -168,12 +168,23 @@ "defaultValue": 0 }, { - "id": "fe616e45-39cb-4be3-8426-9d27c7a64f06", - "name": "chargeEnergy", - "displayName": "Session energy (diagnostic)", + "id": "5b071c75-5e47-406e-ba5d-5c60d5bdd879", + "name": "sessionEnergy", + "displayName": "Session energy", "type": "double", "unit": "KiloWattHour", - "defaultValue": 0 + "defaultValue": 0, + "cached": true, + "suggestLogging": true + }, + { + "id": "fe616e45-39cb-4be3-8426-9d27c7a64f06", + "name": "chargeEnergy", + "displayName": "Charge energy (raw charger counter)", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0, + "cached": true }, { "id": "71773194-ebd3-4ded-893c-14bfef9b1854", @@ -208,28 +219,28 @@ }, { "id": "a1b2c3d4-e5f6-7890-ab12-cd34ef567890", - "name": "powerL1", - "displayName": "Phase L1 power", + "name": "currentL1", + "displayName": "Phase L1 current", "type": "double", - "unit": "Watt", + "unit": "Ampere", "defaultValue": 0, "cached": false }, { "id": "b2c3d4e5-f6a7-8901-bc23-de45fa678901", - "name": "powerL2", - "displayName": "Phase L2 power", + "name": "currentL2", + "displayName": "Phase L2 current", "type": "double", - "unit": "Watt", + "unit": "Ampere", "defaultValue": 0, "cached": false }, { "id": "c3d4e5f6-a7b8-9012-cd34-ef56ab789012", - "name": "powerL3", - "displayName": "Phase L3 power", + "name": "currentL3", + "displayName": "Phase L3 current", "type": "double", - "unit": "Watt", + "unit": "Ampere", "defaultValue": 0, "cached": false }, diff --git a/v2c/trydanmodbusmaster.h b/v2c/trydanmodbusmaster.h index 9a7bae5..ad02172 100644 --- a/v2c/trydanmodbusmaster.h +++ b/v2c/trydanmodbusmaster.h @@ -37,7 +37,7 @@ public: enum ReadRegister : quint16 { RegChargeState = 0x0BC2, ///< 0=A(disconnected) 1=B(connected) 2=C(charging) RegChargePower = 0x0BC3, ///< W - RegChargeEnergy = 0x0BC4, ///< kWh session — diagnostic only, NOT sessionEnergy + RegChargeEnergy = 0x0BC4, ///< kWh, remis à zéro à chaque ARRÊT de charge — source de sessionEnergy, pas sessionEnergy RegSlaveError = 0x0BC5, ///< firmware error code (0-10, cf. decodeSlaveError table) RegChargeTime = 0x0BC6, ///< s, session duration (confirmed hw fw2.4.6, not yet exposed) RegHousePower = 0x0BC8, ///< W, CT clamp (if installed) @@ -49,9 +49,15 @@ public: RegMinIntensity = 0x0BD1, ///< A, lower bound RegMaxIntensity = 0x0BD2, ///< A, upper bound (firmware-version–dependent, verify on hardware) RegPauseDynamic = 0x0BD3, ///< 0=optimizer runs 1=optimizer suspended by HEMS - RegPower_L1 = 0x0BD9, ///< W, phase L1 power (confirmed hw fw2.4.6) - RegPower_L2 = 0x0BDA, ///< W, phase L2 power - RegPower_L3 = 0x0BDB, ///< W, phase L3 power + // \warning La feuille V2C intitule ces registres « Power_L1/L2/L3 », mais ils + // portent des AMPÈRES, pas des watts. Mesuré sur borne le 2026-08-28 : + // consigne 6 A → 5,9-6,0 lus ; consigne 10 A → 9,x-10 lus. Une puissance de + // phase vaudrait ~1 380 W sous 230 V, pas 5,9. Le nom d'enum garde l'intitulé + // du constructeur (c'est ainsi qu'on retrouve l'adresse dans la feuille) ; les + // accesseurs et les states publient la grandeur réelle : currentL1/L2/L3, en A. + RegPower_L1 = 0x0BD9, ///< A, phase L1 current (confirmed hw fw2.4.6) + RegPower_L2 = 0x0BDA, ///< A, phase L2 current + RegPower_L3 = 0x0BDB, ///< A, phase L3 current }; /*! @@ -75,7 +81,15 @@ public: int chargeState() const { return m_chargeState; } /*! \brief ChargePower in watts */ float chargePower() const { return m_chargePower; } - /*! \brief Session energy in kWh (diagnostic only — firmware reliability unvalidated) */ + /*! + * \brief Compteur d'énergie brut de la borne, en kWh. + * + * \warning Ce n'est PAS l'énergie de session : le firmware le remet à zéro à + * chaque ARRÊT de charge, câble toujours branché (mesuré le 2026-08-28 : remise + * à zéro 8 s après l'arrêt, débranchement seulement 119 s plus tard). Le plugin + * en accumule les incréments pour construire sessionEnergy — cf. + * IntegrationPluginV2c::accumulerEnergieDeSession(). + */ float chargeEnergy() const { return m_chargeEnergy; } /*! \brief Firmware error code */ int slaveError() const { return m_slaveError; } @@ -97,12 +111,12 @@ public: int maxIntensity() const { return m_maxIntensity; } /*! \brief PauseDynamic: 0=optimizer runs, 1=HEMS has suspended it */ int pauseDynamic() const { return m_pauseDynamic; } - /*! \brief Phase L1 power in watts (diagnostic, 0 if not available) */ - float powerL1() const { return m_powerL1; } - /*! \brief Phase L2 power in watts (diagnostic, 0 if not available) */ - float powerL2() const { return m_powerL2; } - /*! \brief Phase L3 power in watts (diagnostic, 0 if not available) */ - float powerL3() const { return m_powerL3; } + /*! \brief Phase L1 current in amperes (diagnostic, 0 if not available) */ + float currentL1() const { return m_currentL1; } + /*! \brief Phase L2 current in amperes (diagnostic, 0 if not available) */ + float currentL2() const { return m_currentL2; } + /*! \brief Phase L3 current in amperes (diagnostic, 0 if not available) */ + float currentL3() const { return m_currentL3; } bool reachable() const { return m_reachable; } @@ -214,9 +228,9 @@ protected: int m_minIntensity = 6; int m_maxIntensity = 32; int m_pauseDynamic = 0; - float m_powerL1 = 0.0f; - float m_powerL2 = 0.0f; - float m_powerL3 = 0.0f; + float m_currentL1 = 0.0f; + float m_currentL2 = 0.0f; + float m_currentL3 = 0.0f; bool m_reachable = false; }; diff --git a/v2c/trydanmodbustcpmaster.cpp b/v2c/trydanmodbustcpmaster.cpp index 90dc7a3..4ad4c95 100644 --- a/v2c/trydanmodbustcpmaster.cpp +++ b/v2c/trydanmodbustcpmaster.cpp @@ -191,13 +191,13 @@ QList TrydanModbusTcpMaster::buildPollSteps() if (ok) m_pauseDynamic = decodeIntFromFloat32(h, l); }}, { RegPower_L1, [this](bool ok, quint16 h, quint16 l) { - if (ok) m_powerL1 = decodeFloat32BB(h, l); + if (ok) m_currentL1 = decodeFloat32BB(h, l); }}, { RegPower_L2, [this](bool ok, quint16 h, quint16 l) { - if (ok) m_powerL2 = decodeFloat32BB(h, l); + if (ok) m_currentL2 = decodeFloat32BB(h, l); }}, { RegPower_L3, [this](bool ok, quint16 h, quint16 l) { - if (ok) m_powerL3 = decodeFloat32BB(h, l); + if (ok) m_currentL3 = decodeFloat32BB(h, l); }}, }; }