// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #pragma once #include #include #include "iloadadapter.h" class EvCharger; class EnergyArbitrator; /*! * \brief Adaptateur pour une borne de recharge VE (interface evcharger nymea). * * Traduit les LoadAction(Setpoint) en appels matériels via * EnergyArbitrator::doExecuteChargingAction() — le seul chemin d'exécution. * * \invariant applyAction() rejette silencieusement toute LoadAction dont \c reason est vide. * \invariant currentA est écrêté à [maxChargingCurrentMinValue, maxChargingCurrentMaxValue]. * \invariant phaseCount est écrêté selon canSetPhaseCount() et phaseCount() du EvCharger. * \invariant Les kinds autres que Setpoint sont retournés sans effet. */ class EvAdapter : public QObject, public ILoadAdapter { Q_OBJECT public: /*! * \brief Constructeur. * \param evCharger Borne VE à piloter (doit rester valide tant que l'adaptateur existe). * \param parent EnergyArbitrator propriétaire — utilisé pour l'exécution matérielle. */ explicit EvAdapter(EvCharger *evCharger, EnergyArbitrator *parent); /*! * \brief Retourne la description statique de la charge. * \return LoadDescriptor construit depuis les capacités actuelles du EvCharger. * \note Recalculé à chaque appel depuis l'état nymea du Thing. */ LoadDescriptor descriptor() const override; /*! * \brief Retourne la télémétrie runtime (puissance mesurée, disponibilité). * \return LoadTelemetry avec currentPowerW, available et lastActionAt. */ LoadTelemetry telemetry() const override; /*! * \brief Construit l'entrée loads[] §5 du SurplusContext. * \param now Temps de cycle (\c ctx.timestamp). Inutilisé ici : l'EV n'a pas de verrou * de palier (hors waterfall ECS/SG-Ready). Présent pour l'uniformité de \c ILoadAdapter. * \return LoadContext incluant declared, limits, needs et télémétrie EV. */ LoadContext toLoadContext(const QDateTime &now) const override; /*! * \brief Applique une consigne Setpoint sur la borne VE. * * **Inactif jusqu'à 3g** : non appelée par \c EnergyArbitrator::update() en beta. * Le dispatch EV passe par \c adjustEvChargers() amont (hérité). Cette méthode * sera câblée lors de la transplantation EV dans \c RuleBasedScheduler (phase 3g). * \c descriptor() et \c telemetry() sont eux actifs dès maintenant pour le SurplusContext. * * \param action LoadAction de kind Setpoint. Autres kinds : retour sans effet. * \param now Temps de cycle (\c ctx.timestamp) — passé à \c doExecuteChargingAction() * (locks anti-rebond de la borne). MÊME source que toLoadContext() (contrat ILoadAdapter). * \return L'action après écrêtage matériel (currentA, phaseCount bornés). * * \invariant action.reason non vide requis — log warning et retour sans effet sinon. * \invariant currentA écrêté à [minValue, maxValue] avant envoi à executeChargingAction. * \invariant phaseCount ajusté selon canSetPhaseCount() du EvCharger. */ LoadAction applyAction(const LoadAction &action, const QDateTime &now) override; //! \brief Sans effet : l'EV n'est pas construit depuis \c LoadConfig (rang fixé à 100, //! cf. « Dette 3g »), et son nom d'affichage vient du Thing, pas de la config. //! \param label Ignoré. \param priority Ignoré. \param needs Ignoré. void updateSoftConfig(const QString &label, int priority, const LoadNeeds &needs) override { Q_UNUSED(label) Q_UNUSED(priority) Q_UNUSED(needs) } //! \brief Sans effet : l'EV n'implémente pas encore l'échelle de défaut ECS-410. //! La généralisation est portée par **ECS-414** (lot de mise en configuration //! du SgReadyAdapter). Déclaré explicitement plutôt qu'hérité d'un défaut vide. //! Aucun mécanisme de défaut côté EV : il n'y a jamais rien à lever. Le dire //! explicitement plutôt que de laisser un corps vide (règle 7-c). //! \return Vrai si un défaut a réellement été levé ; faux s'il n'y en avait pas. bool clearFault() override { return false; } /*! * \brief Latences MESURÉES sur la V2C : montée ~30 s (le véhicule négocie), descente ~7 s. * \param montee Vrai si la consigne augmente. * \return Le temps d'établissement correspondant, en secondes. * \note L'asymétrie joue dans le bon sens — le refus qui coûte de l'argent est celui de la * descente, et c'est le plus vite constaté. */ int commandSettleS(bool montee) const override { return montee ? 30 : 7; } /*! * \brief Vue runtime — déclarée, mais HORS de la charge utile \c GetLoadTelemetry. * * \param now Ignoré : les verrous EV (chargingEnabledLock, currentChangeLock) vivent dans * \c SmartChargingManager amont, pas ici. * \return \c mechanismKind "evcharger" et l'état de la borne. * * \note L'EV n'apparaît PAS dans \c NymeaEnergy.GetLoadTelemetry en beta : il est décidé * par l'amont EN AMONT du waterfall (décision B), sa puissance est réservée dans * \c PlanBudget::evReservedW et non allouée depuis le budget publié. L'y faire figurer * ferait une somme \c loads[] qui ne retomberait pas sur \c budget.allocatedW. La * méthode est néanmoins IMPLÉMENTÉE, et non laissée en \c Q_UNIMPLEMENTED : quand 3g * transplantera l'EV dans le waterfall unifié, il n'y aura rien à écrire ici. */ LoadRuntimeView runtimeView(const QDateTime &now) const override; /*! * \brief Mesure propre de la borne (§11 / LM-1105). * \return La puissance que la borne rapporte elle-même, quand sa ThingClass porte l'état. * \note Une borne est **traversée** par la puissance qu'elle délivre : sa mesure est bien * celle de la charge, et non celle d'un organe de commande. C'est le cas qui a motivé * LM-1105 — il fallait jusqu'ici désigner la borne comme son propre \c meterThingId * pour publier une valeur qu'elle donne d'elle-même. */ LoadMeasurement deviceMeasurement() const override; //! \brief Sans effet : l'EV n'est pas construit depuis \c LoadConfig et ne peut donc pas //! être « désactivé » par ce chemin. \param now Ignoré. void applySafeState(const QDateTime &now) override { Q_UNUSED(now) } /*! * \brief Borne VE sous-jacente (lecture). * \return Pointeur non-propriétaire vers l'EvCharger ; jamais nul (fixé à la construction). */ EvCharger *evCharger() const { return m_charger; } /*! * \brief Dernière commande envoyée au matériel par cet adaptateur. * \param[out] ampere Courant commandé (A), 0 si la charge a été coupée. * \param[out] phases Nombre de phases commandé. * \return Faux si AUCUNE commande n'est jamais partie — l'écart est alors sans objet, et * c'est le cas normal d'une borne en mode manuel que l'arbitre ne pilote pas. */ bool derniereCommande(uint &ere, uint &phases) const { ampere = m_commandeA; phases = m_commandePhases; return m_commandeValide; } private: /*! * \brief Dernière commande RÉELLEMENT envoyée au matériel — écrêtée, pas demandée. * * \par Pourquoi ce souvenir n'existait pas * Les adaptateurs de charges en watts gardent leur consigne parce que l'écrêtage de verrou * l'exige. La borne, pilotée par le proxy amont, n'a jamais eu à se relire — et c'est * précisément là que le relevé du 2026-08-31 mord : après une coupure Modbus, la borne est * restée sur une consigne d'arbitre pendant que le proxy en écrivait une autre, sans que * l'écart soit calculable par personne. * * \note La commande ÉCRÊTÉE, jamais la demande : c'est elle qui est partie au matériel. * Comparer la demande ferait voir un écart là où l'adaptateur a fait son travail. */ uint m_commandeA = 0; uint m_commandePhases = 0; //! Faux tant qu'aucune commande n'est partie. L'absence de commande n'est pas une commande //! à zéro : sans ce booléen, une borne jamais pilotée paraîtrait divergente dès qu'elle //! consomme, ce qui est le cas NORMAL du mode manuel. bool m_commandeValide = false; EvCharger *m_charger; EnergyArbitrator *m_parent; QDateTime m_lastActionAt; //! Nombre de phases VISÉ, décidé à la construction du contexte et réutilisé à //! l'exécution. Les deux doivent parler du MÊME, sinon le plancher publié ne décrit pas //! la borne qui sera réellement commandée (§12 / 3g-1). mutable uint m_targetPhases = 1; };