From 3d9748d3c106700fa8d4d7eef3794975a8d4cb3f Mon Sep 17 00:00:00 2001 From: Patrick Schurig Date: Tue, 8 Sep 2026 11:10:01 +0200 Subject: [PATCH] =?UTF-8?q?feat(v2c):=20la=20borne=20d=C3=A9clare=20smartm?= =?UTF-8?q?eterconsumer=20=E2=80=94=20elle=20a=20enfin=20un=20pass=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La classe trydan ne déclarait qu'evcharger + connectable. Ça la tenait hors de EnergyManagerImpl::watchThing(), qui ne branche un Thing sur le journal d'énergie que s'il déclare energymeter, smartmeterconsumer, smartmeterproducer ou energystorage. Energy.GetThingPowerLogs ne REJETAIT pas la demande : il rendait zéro ligne pour ce thingId, et l'app listait la borne en « sans historique » alors que sa puissance instantanée était parfaitement lisible. Deux bornes côte à côte, une seule avec un passé. Au passage, le portier n'est pas `smartmeter` — cette interface est VIDE, aucun state requis, la déclarer n'aurait rien produit. C'est `smartmeterconsumer` qui compte, et elle exige totalEnergyConsumed. Or la borne n'a pas de totalisateur : aucun registre de la carte Modbus V2C n'en porte un, et ChargeEnergy (0x0BC4) est effacé à chaque arrêt de charge. Il est donc CONSTRUIT, comme sessionEnergy, sur les mêmes incréments — et désormais par la même règle unique, incrementEnergie(), partagée par les deux compteurs pour qu'une correction ne puisse jamais s'appliquer à un seul. Contrairement à sessionEnergy il ne se remet jamais à zéro et n'a aucune condition de session : une recharge lancée à la main sur la borne, hors pilotage, y entre aussi. Deux repères distincts (baseline / baselineTotale) et non un seul : la machine à états de session sort par plusieurs chemins qui écrivent baseline sans accumuler, un repère partagé ferait dépendre le total de l'ORDRE des deux appels. CE QUI A LEVÉ LE BLOCAGE « MONOTONE À VIE » Le brief tenait ce totalisateur pour bloqué : la moindre perte se lirait comme une consommation négative en aval. C'est trop strict, et vérifié : le moteur tient son PROPRE compteur, se cale sur la première valeur sans l'ajouter à son diff, et resynchronise explicitement quand le compteur d'un Thing recule (« seems to have been reset »). Une perte côté plugin ne peut ni injecter de négatif en aval, ni effacer le journal déjà écrit, ni produire une marche à l'ajout du Thing. L'exigence réelle est « ne jamais reculer PENDANT une exécution » — ce que le state "cached": true et le repère relu de chargeEnergy au setup assurent. Limite assumée, la même que sessionEnergy : l'énergie livrée entre le dernier poll réussi et une RAZ de la borne est perdue, bornée par un intervalle de poll (30 s). C'est pourquoi ce calcul vit dans le plugin et pas dans le moteur. VÉRIFIÉ SUR LE BANC (.75, 1.15.2+etm14, 2026-09-08 10:57) energylogs.sqlite, pour {4d1adafd-cafc-4813-93b1-ca8933ce71ab} : - thingCache : ligne absente avant, créée après — c'est watchThing() ; - thingPower : 0 ligne avant, puis une par minute comme les cinq autres Things. À SAVOIR : sur un Thing déjà appairé, things.conf écrase le `logged: true` que l'interface porte pour currentPower/totalEnergyConsumed (loggedStateTypeIds y vaut déjà {connected}, {charging}, {power}). Le journal d'énergie du moteur ne dépend PAS de ce réglage — la courbe marche sans y toucher. Co-Authored-By: Claude Opus 5 (1M context) --- debian/changelog | 33 ++++++++++- v2c/integrationpluginv2c.cpp | 102 ++++++++++++++++++++++++++-------- v2c/integrationpluginv2c.h | 51 +++++++++++++++++ v2c/integrationpluginv2c.json | 10 ++++ 4 files changed, 171 insertions(+), 25 deletions(-) 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",