// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2025 - 2026, Patrick Schurig / ETM PowerSync #pragma once #include #include #include "../types/loadaction.h" #include "../types/loaddescriptor.h" #include "../types/surpluscontext.h" /*! * \brief Vue runtime minimale exposée par un adaptateur à l'arbitre. */ struct LoadTelemetry { double currentPowerW = 0; //!< Puissance mesurée (W). bool available = true; //!< Faux si l'appareil nymea est absent ou en erreur. QDateTime lastActionAt; //!< Dernier instant où applyAction() a produit un effet. }; /*! * \brief Mesure PROPRE de l'appareil piloté — §11, LM-1105/LM-1106. * * Distincte de \c LoadTelemetry::currentPowerW, et la distinction est tout l'objet du type. * Ce champ-là est le **juge runtime du budget** : il vaut une mesure quand il en existe une et * la puissance **commandée ou déclarée** sinon, parce que le recrédit anti-clignotement a * besoin d'un nombre à chaque cycle. Celui-ci ne vaut **que** ce qu'un appareil a réellement * mesuré, ou rien. * * \par Pourquoi une CAPACITÉ et non une valeur nulle * \c available répond à « cette charge est-elle mesurable ? », question posée à la * \c ThingClass, jamais à la valeur du moment. Un routeur au palier 0 mesure 0 W et reste * mesurable ; sans ce booléen, les deux seraient indistinguables et la source publiée * basculerait d'un cycle à l'autre sur une installation dont rien n'a changé. */ struct LoadMeasurement { //! Vrai si les Things pilotés portent l'état de puissance — CAPACITÉ, pas valeur. bool available = false; //! Puissance réellement mesurée (W) ; sans objet quand \c available est faux. double powerW = 0; }; /*! * \brief Vue runtime d'une charge destinée à la FRONTIÈRE DE TÉLÉMÉTRIE (RPC), et à elle seule. * * Distincte de \c LoadTelemetry (que l'arbitre lit pour DÉCIDER) et de \c LoadContext (ce que * le scheduler reçoit). Elle porte ce qui n'a de sens que pour EXPLIQUER une décision à un * humain : le défaut, le verrou en secondes restantes, et l'état du mécanisme. * * \par Des codes, jamais des phrases * \c faultCode, \c lockKind et \c mechanismKind sont des CODES d'énumération fermée. La * phrase est fabriquée par le client, dans la langue de son utilisateur — même raison que * pour \c DecisionReason (cf. \c decisionreason.h). * * \par Des secondes, pas des watts * \c lockMinPowerW / \c lockMaxPowerW (dans \c LoadContextTelemetry) sont un artefact du * mécanisme d'allocation : illisibles pour un utilisateur. Le temps restant, lui, se lit. * Ceci n'enfreint pas ECS-306 : cette exigence interdit qu'un index de PALIER franchisse la * frontière d'ALLOCATION. Une valeur d'AFFICHAGE sortante est un autre chemin, et * \c stageW / \c state sont des watts et des états normés, pas des index. * * \par Union discriminée par mécanisme * \c mechanismKind discrimine ; \c mechanism porte la charge utile de la variante, et * d'elle seule (LM-300). Ajouter un mécanisme = une variante ici et son test — rien d'autre. */ struct LoadRuntimeView { //! Code de défaut ; VIDE = aucun défaut. Énumération fermée, cf. \c INTERFACE.md. QString faultCode; //! Verrou ACTIF maintenant : "minOn" | "minOff" | "minStateHold" ; vide = aucun. QString lockKind; //! Secondes restantes du verrou nommé par \c lockKind ; sans objet si \c lockKind est vide. int lockRemainingS = 0; //! Mécanisme : "relay" | "variable" | "sgReady" | "evcharger". QString mechanismKind; //! Charge utile du mécanisme (clés propres à la variante) ; jamais celle d'un autre. QVariantMap mechanism; }; /*! * \brief Interface pure des adaptateurs de charge. * * Les implémentations concrètes héritent de QObject + ILoadAdapter et déclarent leurs * propres signaux (telemetryChanged, descriptorChanged). * * \invariant Les adaptateurs EXÉCUTENT, ils ne décident pas (AGENTS règle 2). * \invariant applyAction() écrête les valeurs selon les limites matérielles réelles * (second filet après l'écrêtage de l'arbitre). * \invariant applyAction() avec un \c reason.code vide doit être rejetée silencieusement. * \invariant Les méthodes non-applyAction() retournent immédiatement (pas de I/O bloquant). * \invariant **Temps = paramètre, jamais l'horloge.** Toute logique temporelle d'un * adaptateur (verrous minOn/minOff, fenêtres, fraîcheur…) utilise EXCLUSIVEMENT le * \c now (= \c ctx.timestamp) reçu en paramètre de \c toLoadContext()/applyAction(). * JAMAIS \c QDateTime::currentDateTime(). C'est cette source unique, partagée avec le * scheduler, qui rend impossible toute divergence décision/exécution et qui rend la * logique injectable en simulation. Contrat pour tout futur adaptateur (SgReady, Battery). */ class ILoadAdapter { public: virtual ~ILoadAdapter() = default; /*! * \brief Description statique de la charge : capacités, limites, priorité, needs. * \return LoadDescriptor construit depuis la configuration matérielle. * \note Peut être rappelé à chaque cycle — l'implémentation doit être légère. */ virtual LoadDescriptor descriptor() const = 0; /*! * \brief Télémétrie runtime (puissance, disponibilité, dernière action). * \return LoadTelemetry issue de l'état courant de l'appareil nymea. */ virtual LoadTelemetry telemetry() const = 0; /*! * \brief Construit l'entrée §5 loads[] pour le SurplusContext. * \param now Temps de cycle (\c ctx.timestamp). Source unique pour l'évaluation des * verrous (minStage/maxStage) — JAMAIS \c QDateTime::currentDateTime() côté adaptateur, * afin que décision (scheduler) et exécution (applyAction) partagent le même temps. * \return LoadContext incluant declared, limits, needs et télémétrie type-spécifique. */ virtual LoadContext toLoadContext(const QDateTime &now) const = 0; /*! * \brief Applique l'action et retourne ce qui a réellement été envoyé au matériel. * * L'arbitre a déjà écrêté selon les limites et le budget — ceci est le second filet. * * \param action Action à appliquer. Doit avoir \c reason.code non vide. * \param now Temps de cycle (\c ctx.timestamp) — MÊME source que toLoadContext(), * pour que l'évaluation des verrous coïncide avec celle vue par le scheduler. * \return L'action après écrêtage matériel (peut différer de l'entrée). * \note Retour silencieux sans effet si \c action.reason.code est vide. */ virtual LoadAction applyAction(const LoadAction &action, const QDateTime &now) = 0; /*! * \brief Met à jour les champs de configuration qui ne touchent PAS le matériel. * * Sert au rebuild INCRÉMENTAL (ECS-412) : quand seul le rang de service ou les besoins * changent, l'arbitre met à jour l'adaptateur en place au lieu de le détruire. Détruire * réarmerait les verrous — sur un ballon thermodynamique à \c minOn de 300 à 600 s, un * client qui réordonne ses priorités depuis l'app pourrait faire court-cycler son * compresseur — et provoquerait des réécritures de relais inutiles. * * \param label Nouveau libellé d'affichage. * \param priority Nouveau rang de service (ASC, 1 = premier servi). * \param needs Nouveaux besoins déclarés. * \warning NE DOIT jamais toucher au câblage, aux paliers ni aux verrous : ces * changements-là exigent une vraie reconstruction. * \note **Pure virtuelle à dessein**, sans implémentation par défaut. Un adaptateur qui * n'est pas construit depuis \c LoadConfig n'a rien à mettre à jour, mais il doit le * DÉCLARER : un défaut vide silencieux ferait qu'un futur adaptateur construit depuis * la config ignorerait sans bruit les changements de rang. */ virtual void updateSoftConfig(const QString &label, int priority, const LoadNeeds &needs) = 0; /*! * \brief Lève le verrou de défaut de la charge (ECS-410). * * Le défaut est COLLANT par conception — « cesser toute commande jusqu'à intervention ». * Sa levée est un acte délibéré de l'opérateur, jamais une expiration : sans quoi * \c available oscillerait d'un cycle à l'autre et le waterfall réagirait à chaque * battement. * * \note **Pure virtuelle à dessein.** Tous les adaptateurs ne savent pas encore tomber en * défaut — seul \c RelayRouter implémente l'échelle ECS-410 — mais chacun doit le * DÉCLARER. La généralisation est portée par **ECS-414**. * * \return \c true si un défaut a RÉELLEMENT été levé, \c false s'il n'y en avait pas. * * \note **Pourquoi un booléen et non \c void** (règle 7-c d'AGENTS.md). C'est le seul * levier de reprise à distance : ECS-410 pose un verrou collant que seuls ce RPC ou une * reconstruction lèvent. Sans valeur de retour, l'arbitre ne peut pas distinguer * « défaut levé » de « aucun défaut à lever », et un opérateur à distance ne peut pas * savoir si son geste a agi — il relancera, puis conclura que le système est cassé. * Le type de retour force chaque implémentation, présente et future, à répondre à la * question ; une ligne de journal dans chaque adaptateur se serait oubliée à la * cinquième. */ virtual bool clearFault() = 0; /*! * \brief Temps au-delà duquel un écart commandé/mesuré n'est PLUS une latence. * * \param montee Vrai si la consigne AUGMENTE, faux si elle diminue. * \return Durée d'établissement du mécanisme, en secondes. * * \par Pourquoi une valeur PAR MÉCANISME, et pas une constante * L'anti-clignotement protège contre la latence : une charge qui vient d'être commandée n'a * pas encore atteint sa consigne, et suspendre le recrédit là ferait osciller une charge * saine. Mais un écart qui **persiste** au-delà de cette durée n'est plus une latence, * **c'est un refus** — et prêter contre un refus établi, c'est importer des kilowatts que * personne n'a décidés. * * Un seuil unique serait **trop court pour une borne et trop long pour un relais** : mesuré * sur la V2C, la montée prend ~30 s et la descente ~7 s. Le seuil se raisonne donc contre la * latence connue du mécanisme, jamais contre un nombre choisi une fois pour toutes. * * \note L'ASYMÉTRIE est utile dans le bon sens : le cas dangereux — commander la baisse et * ne pas être suivi — se juge sur la latence de DESCENTE, la plus courte. Le refus qui * coûte de l'argent se constate donc plus vite que celui qui n'en coûte pas. * * \note Défaut prudent : 60 s dans les deux sens. Un mécanisme qui ne se déclare pas ne doit * pas voir son recrédit suspendu trop tôt — l'oscillation d'une charge saine est un coût * certain, le refus n'est qu'un risque. */ virtual int commandSettleS(bool montee) const { Q_UNUSED(montee) return 60; } /*! * \brief Vue runtime destinée à la frontière de télémétrie RPC (\c GetLoadTelemetry). * * \param now Temps de cycle — MÊME source que \c toLoadContext()/\c applyAction(), afin * que le verrou annoncé au client soit celui qui a réellement borné la décision. * \return Défaut, verrou en secondes restantes, et charge utile du mécanisme. * * \note **Pure virtuelle à dessein**, sans implémentation par défaut. Un mécanisme ajouté * qui hériterait d'un défaut vide sortirait de la télémétrie SANS BRUIT : le client * verrait une charge sans mécanisme et sans verrou, indistinguable d'une charge libre. * C'est précisément le silence ambigu de la règle 7-c. Le compilateur pose donc la * question à chaque nouvel adaptateur. * \note Cette méthode n'entre JAMAIS dans une décision : l'arbitre ne la lit que pour * publier. Un adaptateur qui s'en servirait pour décider violerait la règle 2. */ virtual LoadRuntimeView runtimeView(const QDateTime &now) const = 0; /*! * \brief Cette charge est-elle mesurée par les appareils qu'elle pilote, et combien ? * * §11 — **LM-1105** : le moteur lit l'appareil qu'il pilote plutôt que d'exiger qu'on le * désigne comme son propre compteur dans \c meterThingId. **LM-1106** : ce qui est publié * est un couple {valeur, source}, et c'est cette méthode qui répond pour la source * \c "device". * * \return \c available faux = **rien ne mesure cette charge** (\c source: "none" à la * publication) ; vrai = \c powerW est une mesure réelle. * * \note **La question porte sur la ThingClass, jamais sur la valeur ni sur l'instant.** Un * \c RelayRouter au palier 0 n'a aucun relais fermé : interroger les relais ACTIFS * ferait osciller la source publiée d'un cycle à l'autre. Elle se pose sur TOUS les * Things déclarés de la charge. * \note **Et elle présuppose que le Thing piloté est TRAVERSÉ par la puissance.** Un relais * de \c RelayRouter commute la charge, donc sa puissance EST celle de la charge ; un * contact SG-Ready est un contact de SIGNAL — la PAC est alimentée ailleurs, et son * courant ne passe jamais par lui. Un contact qui mesurerait quelque chose mesurerait sa * propre bobine. \c SgReadyAdapter répond donc toujours « non mesurable », et c'est le * seul mécanisme pour lequel \c meterThingId reste la seule voie. * \note **Une mesure PARTIELLE n'est pas une mesure.** Là où plusieurs Things portent la * charge, il faut qu'ils la portent TOUS : sommer les seuls relais mesurants * publierait une puissance sous-évaluée, et l'écart commandé↔mesuré enverrait chercher * une panne qui n'existe pas (LM-1102). * \note **Pure virtuelle à dessein**, comme \c runtimeView() et pour la même raison : un * mécanisme ajouté qui hériterait d'un défaut « non mesurable » sortirait de la mesure * SANS BRUIT, et son écran afficherait « pas mesurable » avec l'autorité d'un constat. * Le compilateur pose la question à chaque nouvel adaptateur. * \note N'entre JAMAIS dans une décision : l'arbitre ne la lit que pour publier (règle 2). */ virtual LoadMeasurement deviceMeasurement() const = 0; /*! * \brief Amène le matériel dans l'ÉTAT SÛR de cet adaptateur (ECS-413). * * Appelé quand la charge est désactivée (\c enabled: false) ou retirée de la * configuration — un acte DÉLIBÉRÉ de l'opérateur. Sans cela, l'adaptateur est détruit et * le matériel reste dans son DERNIER ÉTAT COMMANDÉ : constaté au banc le 2026-08-09, trois * relais laissés fermés juste avant une intervention de câblage. * * \param now Temps de cycle. Passe par le chemin d'action normal avec \c force = true, * comme le mode dégradé L2 : l'arrêt prime sur les verrous. * * \warning **NE PAS appeler à l'arrêt du plugin ni au redémarrage de nymead.** L'état doit * y être CONSERVÉ — c'est ce qu'ECS-411 relit au démarrage, et couper l'eau chaude à * chaque redémarrage de service serait une régression. La distinction est * intentionnelle : la désactivation est délibérée, un redémarrage ne l'est pas. * * \note L'état sûr est PROPRE à chaque adaptateur — relais ouverts pour un routeur, * consigne 0 W pour une charge continue, mais **état 2** pour une PAC SG-Ready, jamais * le blocage (\c docs/SAFETY.md). Une formulation « tout couper » serait fausse. */ virtual void applySafeState(const QDateTime &now) = 0; };