etm-powersync-plugins-modbus/v2c/integrationpluginv2c.h
Patrick Schurig 3d9748d3c1 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>
2026-09-08 11:10:01 +02:00

180 lines
8.5 KiB
C++

// SPDX-License-Identifier: GPL-3.0-or-later
#ifndef INTEGRATIONPLUGINV2C_H
#define INTEGRATIONPLUGINV2C_H
#include <plugintimer.h>
#include <integrations/integrationplugin.h>
#include "extern-plugininfo.h"
#include "trydanmodbusmaster.h"
#include "trydanmodbustcpmaster.h"
/*!
* \brief nymea integration plugin for the V2C Trydan EV charger.
*
* ThingClass \c trydan implementing the \c evcharger interface via Modbus TCP
* (Étape 1). All register-level I/O is delegated to TrydanModbusTcpMaster;
* this class owns the evcharger business logic:
* - ChargeState → pluggedIn / charging mapping
* - chargingEnabled = (PauseState==0 AND Lock==0)
* - Dynamic optimizer conflict management via PauseDynamic
*
* No NetworkDeviceMonitor: the Trydan ESP32 WiFi stack saturates under
* periodic ICMP pings (latency > 14 s, Modbus timeouts). Connectivity is
* derived from Modbus poll success/failure instead.
*
* Étape 2 (RTU) will add TrydanModbusRtuMaster on the same TrydanModbusMaster
* interface; the logic in this file requires no changes.
*/
class IntegrationPluginV2c : public IntegrationPlugin
{
Q_OBJECT
Q_PLUGIN_METADATA(IID "io.nymea.IntegrationPlugin" FILE "integrationpluginv2c.json")
Q_INTERFACES(IntegrationPlugin)
public:
explicit IntegrationPluginV2c();
void discoverThings(ThingDiscoveryInfo *info) override;
void setupThing(ThingSetupInfo *info) override;
void postSetupThing(Thing *thing) override;
void thingRemoved(Thing *thing) override;
public slots:
void executeAction(ThingActionInfo *info) override;
private:
/*! \brief Apply all evcharger states from the last completed poll. */
void updateThingStates(Thing *thing, TrydanModbusMaster *master);
/*!
* \brief Manage the Dynamic/PauseDynamic conflict on each poll.
*
* If Dynamic==1 (V2C internal optimizer is running) and HEMS is in control
* (chargingEnabled==true), write PauseDynamic=1 to suppress the internal PID
* without disabling the telemetry. Release PauseDynamic=0 when HEMS
* relinquishes control. Dynamic is NEVER written (would silence ChargePower).
* cf. AGENTS.md § CONFLIT and evcc charger/trydan.go.
*/
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 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). */
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 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;
};
/*!
* \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<Thing *, TrydanModbusTcpMaster *> m_tcpMasters;
QHash<Thing *, SuiviDeSession> m_sessions;
};
#endif // INTEGRATIONPLUGINV2C_H