feat(v2c): la borne déclare smartmeterconsumer — elle a enfin un passé

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) <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-09-08 11:10:01 +02:00
parent d526a1eaae
commit 3d9748d3c1
4 changed files with 171 additions and 25 deletions

33
debian/changelog vendored
View File

@ -1,5 +1,36 @@
etm-powersync-plugins-modbus (1.15.2+etm14) trixie; urgency=medium 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. * v2c: the lower current bound now reads RegMinIntensity, like the upper one.
executeAction clamped the top end with master->maxIntensity() but the bottom 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 so the app slider and the action agree; letting them diverge would offer a
value the action turns into a pause. value the action turns into a pause.
-- Patrick Schurig <etm.schurig@gmail.com> Tue, 08 Sep 2026 11:09:26 +0200 -- Patrick Schurig <etm.schurig@gmail.com> Tue, 08 Sep 2026 10:46:04 +0200
etm-powersync-plugins-modbus (1.15.2+etm13) trixie; urgency=medium etm-powersync-plugins-modbus (1.15.2+etm13) trixie; urgency=medium

View File

@ -380,12 +380,16 @@ void IntegrationPluginV2c::updateThingStates(Thing *thing, TrydanModbusMaster *m
thing->setStateValue(trydanCurrentL2StateTypeId, static_cast<double>(master->currentL2())); thing->setStateValue(trydanCurrentL2StateTypeId, static_cast<double>(master->currentL2()));
thing->setStateValue(trydanCurrentL3StateTypeId, static_cast<double>(master->currentL3())); thing->setStateValue(trydanCurrentL3StateTypeId, static_cast<double>(master->currentL3()));
// sessionEnergy AVANT chargeEnergy : l'accumulateur travaille sur la valeur // Les deux grandeurs construites AVANT chargeEnergy : les accumulateurs travaillent
// fraîche du master, mais l'ordre garde les deux states cohérents pour qui les // sur la valeur fraîche du master, mais l'ordre garde les states cohérents pour qui
// lit à la volée. chargeEnergy reste publié sous son nom exact, à côté : c'est la // 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 // mesure brute de la borne, les deux autres sont construites. Si elles divergent un
// divergent un jour, on saura laquelle a menti. // 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); accumulerEnergieDeSession(thing, master);
accumulerEnergieTotale(thing, master);
thing->setStateValue(trydanChargeEnergyStateTypeId, static_cast<double>(master->chargeEnergy())); thing->setStateValue(trydanChargeEnergyStateTypeId, static_cast<double>(master->chargeEnergy()));
const int slaveErr = master->slaveError(); const int slaveErr = master->slaveError();
@ -503,25 +507,14 @@ void IntegrationPluginV2c::accumulerEnergieDeSession(Thing *thing, TrydanModbusM
return; return;
} }
// 5. Session ouverte : accumuler l'écart depuis le dernier poll réussi. // 5. Session ouverte : accumuler l'incrément depuis le dernier poll réussi. La règle
double session = thing->stateValue(trydanSessionEnergyStateTypeId).toDouble(); // des trois cas (montée / bruit / remise à zéro) vit dans incrementEnergie() —
const double ecart = brut - suivi.baseline; // 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) { if (brut <= suivi.baseline - k_bruitEnergieKWh) {
// 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 (" qCDebug(dcV2C()) << thing->name() << ": chargeEnergy remis a zero par la borne ("
<< suivi.baseline << "->" << brut << "kWh), session cumulee" << suivi.baseline << "->" << brut << "kWh), session cumulee"
<< session << "kWh"; << session << "kWh";
@ -531,6 +524,59 @@ void IntegrationPluginV2c::accumulerEnergieDeSession(Thing *thing, TrydanModbusM
thing->setStateValue(trydanSessionEnergyStateTypeId, session); 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<double>(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) void IntegrationPluginV2c::restaurerSuiviDeSession(Thing *thing)
{ {
SuiviDeSession suivi; SuiviDeSession suivi;
@ -542,6 +588,12 @@ void IntegrationPluginV2c::restaurerSuiviDeSession(Thing *thing)
// zéro sans que rien ne le signale. // zéro sans que rien ne le signale.
suivi.baseline = thing->stateValue(trydanChargeEnergyStateTypeId).toDouble(); 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(); QSettings *stockage = pluginStorage();
stockage->beginGroup(thing->id().toString()); stockage->beginGroup(thing->id().toString());
suivi.ouverte = stockage->value("sessionOuverte", false).toBool(); 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 =" qCDebug(dcV2C()) << thing->name() << ": reprise du suivi de session — ouverte ="
<< suivi.ouverte << ", repere chargeEnergy =" << suivi.baseline << suivi.ouverte << ", repere chargeEnergy =" << suivi.baseline
<< "kWh, sessionEnergy restauree =" << "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) void IntegrationPluginV2c::ecrireSessionOuverte(Thing *thing, bool ouverte)

View File

@ -79,6 +79,46 @@ private:
*/ */
void accumulerEnergieDeSession(Thing *thing, TrydanModbusMaster *master); 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). */ /*! \brief Restaurer l'état de session persisté (appelé depuis setupThing). */
void restaurerSuiviDeSession(Thing *thing); void restaurerSuiviDeSession(Thing *thing);
/*! \brief Persister « une session est ouverte » (écrit sur transition seulement). */ /*! \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) double baseline = 0.0; ///< dernière valeur brute de chargeEnergy (kWh)
bool ouverte = false; ///< un véhicule est branché : session en cours bool ouverte = false; ///< un véhicule est branché : session en cours
int absences = 0; ///< polls réussis consécutifs lus « débranché » 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;
}; };
/*! /*!

View File

@ -15,6 +15,7 @@
"displayName": "Trydan", "displayName": "Trydan",
"interfaces": [ "interfaces": [
"evcharger", "evcharger",
"smartmeterconsumer",
"connectable" "connectable"
], ],
"createMethods": [ "createMethods": [
@ -167,6 +168,15 @@
"type": "uint", "type": "uint",
"defaultValue": 0 "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", "id": "5b071c75-5e47-406e-ba5d-5c60d5bdd879",
"name": "sessionEnergy", "name": "sessionEnergy",