// SPDX-License-Identifier: GPL-3.0-or-later #ifndef INTEGRATIONPLUGINV2C_H #define INTEGRATIONPLUGINV2C_H #include #include #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 m_tcpMasters; QHash m_sessions; }; #endif // INTEGRATIONPLUGINV2C_H