diff --git a/debian/changelog b/debian/changelog index 8b0fe29..2def2bb 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,5 +1,36 @@ etm-powersync-plugins-modbus (1.15.2+etm14) trixie; urgency=medium + * v2c: the charger now declares smartmeterconsumer, so it has a power history. + + Until now the trydan class declared only evcharger + connectable. That kept + it out of EnergyManagerImpl::watchThing(), which wires a Thing into the + energy log only if it declares energymeter, smartmeterconsumer, + smartmeterproducer or energystorage. Energy.GetThingPowerLogs did not reject + the request -- it simply returned no rows for that thingId, and the app + listed the charger as "no history" while its instantaneous power was + perfectly readable. Two chargers side by side, only one with a past. + + smartmeterconsumer requires totalEnergyConsumed, which the charger does not + provide: there is no lifetime counter anywhere in the Modbus map, and + ChargeEnergy (0x0BC4) is wiped on every charge stop. So it is BUILT, like + sessionEnergy, from the same increments -- and now through the same single + rule, incrementEnergie(), shared by both counters so that a correction can + never apply to only one of them. Unlike sessionEnergy it never resets and + has no session condition, so a charge started by hand on the charger, + outside HEMS control, is counted too. + + The brief held this blocked on "must be monotonic for life". That was too + strict, and checked: the engine keeps its own counter, syncs onto the first + value without adding it to its diff, and explicitly re-syncs when a Thing's + counter goes backwards. A loss on the plugin side can neither inject + negative consumption downstream nor erase the log already written. The real + requirement is "never rewind during a run", which the cached state plus the + baseline re-read from chargeEnergy at setup delivers. + + NOTE: on an already-paired Thing, things.conf overrides the logged:true the + interface carries for currentPower/totalEnergyConsumed. The engine's energy + log does not depend on that setting -- the curve works without touching it. + * v2c: the lower current bound now reads RegMinIntensity, like the upper one. executeAction clamped the top end with master->maxIntensity() but the bottom @@ -11,7 +42,7 @@ etm-powersync-plugins-modbus (1.15.2+etm14) trixie; urgency=medium so the app slider and the action agree; letting them diverge would offer a value the action turns into a pause. - -- Patrick Schurig Tue, 08 Sep 2026 11:09:26 +0200 + -- Patrick Schurig Tue, 08 Sep 2026 10:46:04 +0200 etm-powersync-plugins-modbus (1.15.2+etm13) trixie; urgency=medium diff --git a/v2c/integrationpluginv2c.cpp b/v2c/integrationpluginv2c.cpp index 4b86be3..da81ba3 100644 --- a/v2c/integrationpluginv2c.cpp +++ b/v2c/integrationpluginv2c.cpp @@ -380,12 +380,16 @@ void IntegrationPluginV2c::updateThingStates(Thing *thing, TrydanModbusMaster *m 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. + // 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(); @@ -503,25 +507,14 @@ void IntegrationPluginV2c::accumulerEnergieDeSession(Thing *thing, TrydanModbusM 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; + // 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 (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; + 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"; @@ -531,6 +524,59 @@ void IntegrationPluginV2c::accumulerEnergieDeSession(Thing *thing, TrydanModbusM 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; @@ -542,6 +588,12 @@ void IntegrationPluginV2c::restaurerSuiviDeSession(Thing *thing) // 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(); @@ -563,7 +615,9 @@ void IntegrationPluginV2c::restaurerSuiviDeSession(Thing *thing) qCDebug(dcV2C()) << thing->name() << ": reprise du suivi de session — ouverte =" << suivi.ouverte << ", repere chargeEnergy =" << suivi.baseline << "kWh, sessionEnergy restauree =" - << thing->stateValue(trydanSessionEnergyStateTypeId).toDouble() << "kWh"; + << thing->stateValue(trydanSessionEnergyStateTypeId).toDouble() + << "kWh, totalEnergyConsumed restaure =" + << thing->stateValue(trydanTotalEnergyConsumedStateTypeId).toDouble() << "kWh"; } void IntegrationPluginV2c::ecrireSessionOuverte(Thing *thing, bool ouverte) diff --git a/v2c/integrationpluginv2c.h b/v2c/integrationpluginv2c.h index 61c7c61..9a30a65 100644 --- a/v2c/integrationpluginv2c.h +++ b/v2c/integrationpluginv2c.h @@ -79,6 +79,46 @@ private: */ void accumulerEnergieDeSession(Thing *thing, TrydanModbusMaster *master); + /*! + * \brief Construire totalEnergyConsumed — le totalisateur exigé par + * \c smartmeterconsumer, que la borne ne fournit pas. + * + * Même matière première que sessionEnergy (les incréments de chargeEnergy) et même + * règle d'incrément — \c incrementEnergie() la porte une seule fois pour les deux — + * mais une portée et une contrainte différentes : il ne se remet JAMAIS à zéro, ni + * au débranchement, ni au redémarrage. Il accumule donc aussi hors session, ce qui + * couvre la recharge lancée à la main sur la borne, hors pilotage. + * + * \par Ce qui rend ce total tenable, alors qu'un totalisateur « monotone à vie » est + * une exigence forte pour un plugin (cf. AGENTS.md § INTERFACES DE COMPTAGE) : + * il n'est PAS la source de vérité des courbes. Le moteur tient son propre compteur + * interne, démarré à zéro à l'ajout du Thing, et \c EnergyManagerImpl::updateThingPower() + * resynchronise explicitement quand le compteur d'un Thing recule (« seems to have been + * reset »). Une perte ici — Thing retiré puis rajouté — repart de zéro sans injecter de + * consommation négative en aval, et sans effacer le journal déjà écrit. + * + * Ce que ça coûte quand même, et qu'il ne faut pas se cacher : l'énergie livrée entre + * le dernier poll réussi et une remise à zéro de la borne est perdue, bornée par un + * intervalle de poll (30 s). C'est la même limite que sessionEnergy, pour la même + * raison — et c'est pourquoi ce calcul vit dans le plugin et pas dans le moteur. + */ + void accumulerEnergieTotale(Thing *thing, TrydanModbusMaster *master); + + /*! + * \brief Énergie ajoutée depuis le dernier poll réussi, remise à zéro comprise. + * + * Trois cas, et un seul endroit où ils sont écrits : + * - le compteur monte → l'écart ; + * - il recule de moins de k_bruitEnergieKWh → rien, c'est du bruit de lecture ; + * - il recule franchement → \a brut : la borne a remis son compteur à zéro (arrêt de + * charge, câble encore branché), et ce qu'elle a compté DEPUIS est tout ce qui reste + * à ajouter — les incréments d'avant sont déjà cumulés. + * + * \param brut valeur de chargeEnergy au poll courant (kWh) + * \param repere valeur de chargeEnergy au dernier poll réussi (kWh) + */ + static double incrementEnergie(double brut, double repere); + /*! \brief Restaurer l'état de session persisté (appelé depuis setupThing). */ void restaurerSuiviDeSession(Thing *thing); /*! \brief Persister « une session est ouverte » (écrit sur transition seulement). */ @@ -97,6 +137,17 @@ private: 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 Repère du totalisateur — délibérément SÉPARÉ de \c baseline. + * + * Les deux suivent la même grandeur et valent la même chose après chaque poll, + * mais la machine à états de session sort par plusieurs chemins (ouverture, + * véhicule absent) qui écrivent \c baseline sans accumuler. Un repère partagé + * ferait dépendre le total de l'ORDRE des deux appels ; un repère propre le rend + * insensible à cet ordre — et à la prochaine étape ajoutée dans la session. + */ + double baselineTotale = 0.0; }; /*! diff --git a/v2c/integrationpluginv2c.json b/v2c/integrationpluginv2c.json index ca1695d..25c2b57 100644 --- a/v2c/integrationpluginv2c.json +++ b/v2c/integrationpluginv2c.json @@ -15,6 +15,7 @@ "displayName": "Trydan", "interfaces": [ "evcharger", + "smartmeterconsumer", "connectable" ], "createMethods": [ @@ -167,6 +168,15 @@ "type": "uint", "defaultValue": 0 }, + { + "id": "90b445eb-fb82-4a58-95f1-1403a59ac975", + "name": "totalEnergyConsumed", + "displayName": "Total energy consumed", + "type": "double", + "unit": "KiloWattHour", + "defaultValue": 0, + "cached": true + }, { "id": "5b071c75-5e47-406e-ba5d-5c60d5bdd879", "name": "sessionEnergy",